diff --git a/everwith-claude-frontend/.gitignore b/everwith-claude-frontend/.gitignore new file mode 100644 index 00000000..3657ae15 --- /dev/null +++ b/everwith-claude-frontend/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +# Copied by `npm run assets:sync` from the private handoff package (hash-verified). +public/handoff/ +src/vendor/ +# Test output +test-results/ +playwright-report/ +evidence/raw/ diff --git a/everwith-claude-frontend/ACCEPTANCE.md b/everwith-claude-frontend/ACCEPTANCE.md new file mode 100644 index 00000000..3bd51371 --- /dev/null +++ b/everwith-claude-frontend/ACCEPTANCE.md @@ -0,0 +1,95 @@ +# 验收记录(A01–A24) + +> **性质:作者自查,不是独立验收。** 全部结果来自本仓库的自动化测试与证据文件,运行在云端 Linux 容器里的 **headless Chromium 141 + SwiftShader 软件 WebGL**(不是你的电脑、不是真实 GPU、没有真实输入法和声音输出)。所有服务都是浏览器内的本地 mock:**没有任何一项是 PASS-real**。 + +- 代码版本(逐文件清单与重算方法见 `SOURCE_MANIFEST.txt`;不含 `node_modules/`、`public/handoff/`、`src/vendor/`、`dist/`、证据): + - 应用代码 SHA-256 `fde68002697dd16e7292b4c6b1119b232fa57dd09a665c381a850102ea0dc670`(158 个文件,含 `public/generated/batch-01/` 的 46 个生成素材副本)——下面所有端到端结果都在这同一份应用代码的生产构建上取得。 + - 测试代码 SHA-256 `cd1bc7b1b68a465aab785f10326d99acf376ff6b6da7bc87900c88a530f81fcd`(16 个文件)。 +- 验证日期:2026-10-14(接入生成素材后整套重跑;上一轮 2026-10-13 的证据已被这一轮替换);Node 22.22.0 / npm 10.9;Playwright 1.56.0(Chromium revision 1194) +- 单元/集成:`npm run check` → **54/54 通过**(5 个文件),TypeScript strict 无错误 +- 端到端:`npx playwright test` → **29/29 通过**(8 个 spec,明细与各自运行时间见 `evidence/reports/e2e-summary.json`) +- 干净目录复现:**通过**——只复制源码与锁文件(180 个文件)到新目录 → `npm ci` → `assets:sync`(186 个文件、0 缺失、0 不匹配)→ `assets:check` → `assets:refs`(37 处引用、0 断链)→ `assets:generated`(46 个生成素材副本、0 问题)→ `check`(54/54)→ `build`(94 个 JS/CSS 与端到端所用构建逐字节相同)→ `dev` 首页、入口模块、Live2D 模型均 200。npm 使用本机已有缓存(依赖仍按 lockfile 的完整性哈希校验) + +状态含义: + +| 状态 | 含义 | +|---|---| +| **PASS(mock)** | 行为在本地 mock 适配器上经自动化测试通过;不代表真实服务端到端 | +| **PASS(local)** | 纯前端行为(不依赖服务)经自动化测试通过 | +| **PARTIAL** | 部分子项已验证;未验证子项写在“缺口”列 | +| **MOCK** | 依赖的服务未接通,只有本地模拟 | +| **UNTESTED** | 未验证 | +| **FAIL** | 已知不满足 | + +## 逐项 + +| ID | 状态 | 验证方式与证据 | 缺口 / 说明 | +|---|---|---|---| +| A01 | PASS(mock) | `core.spec` 首访落在发现页、标签筛选、搜索空态与“加载失败”文案不同、深链进入故事后 Back 回到原标签;游客直接开局三款游戏(`games.spec`)与故事(`story-create.spec`);`extra.spec` 牌桌上登录取消后仍在同一局、命名框保留原名;刷新:私聊草稿、故事草稿、牌局刷新后同一 ID。截图 `discover-*.png`、`friend-naming-after-signin.png` | “加载更多”失败(`discover.append_error`)只有 mock 开关,未写自动化 | +| A02 | PASS(mock) | 导航五个入口键盘可达(`keyboard.json` Tab 顺序);`extra.spec` 已有好友时根路径落到好友页、最近记录“继续”回到原局同一 URL;发现页无热度/播放量等数字(目录数据里没有此类字段) | 页面完整度靠作者目检;“无空功能”未做自动化穷举 | +| A03 | PASS(mock) | 单元:同 key 幂等、同 key 不同意图拒绝、同源两次加好友是两个人(编号 1/2)、重置后 epoch+1 旧会话只读、过期 epoch 改名被拒;`extra.spec` 临时牌友原位转好友(同一局、席位数不变);`core.spec` 命名中途登录后名字恢复;资料弹窗显示来源、编号、epoch | “另建同源好友”的界面路径只做了手动走查,自动化在单元层 | +| A04 | PARTIAL | 单元(jsdom 合成事件):Enter 发送、Shift+Enter 换行、compositionstart/isComposing/keyCode 229/compositionend 后紧跟的 Enter 都不发送、选区加星号;`layout-a11y.spec` 用 CDP `Input.imeSetComposition` 合成输入法:组字中 Enter 不发送、上屏后 Enter 发送一次;中英切换见布局矩阵;输入框自动增高到上限后内部滚动 | **真实 macOS / Windows 中文输入法未测**(需要人工);超长草稿只验证了 2000 字上限与自动增高,没做性能测量 | +| A05 | PASS(mock) | `edge.spec`:双击发送只产生一轮且第二击不会触发替换出来的“停止”(组件同样有单测);停止与提交赛跑 → 提示“已完成”并保留结果;`core.spec`:结果未知先“查询原请求”、可重试失败后“重试同一条”只一轮;停止后半段不进入记录且原文放回输入框;登录过期时原文放回、无记录 | 断线只用 mock 丢回执模拟,没有真实网络中断 | +| A06 | PARTIAL | `edge.spec`:重试后旧 run 的晚到帧被拒(页面显示“已拒绝 2 个旧 run 的晚到帧”,旧片段不出现);另一窗口先提交(stale head)→ 读最新、草稿保留、不自动重发;好友在别处被重置 → epoch 变化提示、旧会话只读可回看;生成中离开页面再回来只有一轮;单元:run_epoch 检查 | 生成中途切换账号、两个标签页并发的真实竞态未做自动化 | +| A07 | PARTIAL | `edge.spec`(1280×640):回复流式到达时滚到最上方,`scrollTop` 全程保持 0,出现“n 条新消息”按钮;打开/关闭侧栏、打开/关闭资料弹窗、刷新后:阅读锚点相同、草稿与选区(3–5)保留;报告 `chat-reading-position.json` | 私聊里没有图片消息功能(本地 mock 无图片回复),“图片往返”不适用;“放大往返”只在角色页验证了 Live2D 放大,没有专门测私聊滚动 | +| A08 | PASS(local) | 单元:`*动作*` 标记、奇数个反斜杠转义星号、偶数个保留且星号仍生效、段内段外同一规则、未闭合/空星号保持原样、HTML 作为文本显示不执行、换行保留 | — | +| A09 | PARTIAL | mock 服务端强校验:只能替换开放角色、同一好友只能一个角色、锁定角色不可替换、AI ≤ 10;单元覆盖前三项与“入局好友不带私聊内容”(私聊里的秘密词不出现在故事数据里);准备页确认前不建记录(`story-create.spec`) | 两个本地故事都只有 3 个角色,“十个 AI 上限”只有服务端校验没有界面压力测试;“角色能力适配”只用了 Live2D 可用性和角色是否可替换,没有更细的能力模型 | +| A10 | PARTIAL | `story-create.spec`:只有整轮校验通过才提交,状态(线索/目标)与正文同一 revision;“继续剧情”不生成玩家发言;回退后正文与状态一起撤回、该轮输入回到草稿、revision 递增;校验失败不留半轮、可重试同一轮;单元:入局好友被重置后该局只读、拒绝新回合 | **故事内“纠错”未实现**(私聊里有“纠正事实”);故事内的 Reset/移除角色没有单独入口,只通过好友重置→本局只读体现 | +| A11 | PASS(mock) | `games.spec` 三款游戏都是准备页明确“开始”;暂停 AI 2.5 秒 revision 不动;刷新后同一 ID、离开期间后续行动暂停并提示;原桌再开保留同一批人(Royale);`edge.spec`:开局回执丢失 → 按原 key 查到唯一一局;打开不存在的局 → 报错 + 重试/最近,不建替代局 | — | +| A12 | PASS(mock) | `games.spec`:开关侧栏、沉浸、浏览器全屏后仍是同一个已挂载的牌面节点(DOM 标记未丢)、同一 URL、暂停期间 revision 不变;`edge.spec`:浏览器拒绝全屏 → 提示并保留布局;规则弹窗 Esc 两次只关一层、焦点回到“规则”按钮,沉浸中仍能掷骰并推进 revision | 全屏在 headless 下由 Chromium 模拟;没有在真实浏览器窗口里人工确认 | +| A13 | PASS(mock) | 冻结引擎 2–5 人完整对局(单元);`games.spec` 新界面 4 人三轮到最终结算,玩家席位只靠点击可见按钮,正常速度录屏 `evidence/videos/royale-full-game.webm`(316 秒),结果 `reports/game-royale.json`:r149 结束、玩家 30 次操作、用过“过”、四人总值 34万/45万/62万/58万、胜者 2 号席(Arthur);`reports/game-royale-rematch.json` 原桌再开同名新会话 | NPC 是固定策略 mock | +| A14 | PASS(mock) | 冻结引擎完整对局 + v1 旧存档按原规则完成(单元);`games.spec` 4 人到 15 分后补齐本轮,过程中用到预留与归还,录屏 `videos/splendor-full-game.webm`(378 秒),结果 `reports/game-splendor.json`:r123 结束、玩家 30 次操作、用过预留与归还、Lena 得到 2 位贵族、得分 15/17/7/14(玩家先到 15 分,补齐本轮后 Lena 17 分)、胜者 1 号席(Lena);`extra.spec` v1 旧存档打开后按 v1 合法动作继续 | — | +| A15 | PASS(mock) | 单元:射月、失败 CABO +5、叫牌者并列最低获胜、恰好 100 重置为 50 一次、>100 终局;`games.spec` 4 人累计到 >100 终局(开局窥牌→确认盖回→抽牌/换牌/弃牌/能力/叫 CABO 均通过可见按钮),录屏 `videos/cabo-full-game.webm`(564 秒),结果 `reports/game-cabo.json`:5 轮、r274 结束、玩家 73 次操作、玩家叫过 CABO、用过偷看/窥探/盲换三种能力、累计 126/102/37/38(玩家与 Lena 超过 100 终局)、胜者 2 号席(Arthur,37);组件假设(“临时组件假设(未核实)”与能力点数)在牌桌、规则与准备页可见 | 组件档案本身是 provisional(交接包如此标注);录屏这一局:玩家第 2 轮叫 CABO 失败(39 → +44,含 +5),NPC 做过多张匹配交换(Marcus 第 3 轮只剩 3 张);“恰好 100 重置/射月”只在单元测试覆盖,这一局没有出现(玩家累计 17→61→76→95→126) | +| A16 | PASS(mock) | 响应校验拒绝含 `truth/rng/seed/up/lastAction` 的投影;单元:对手盖牌只渲染牌背、无数字,自己已知牌只有“已知”标记;他人暗预留只显示等级牌背、无卡 ID;`edge.spec` 桌聊台词失败时 revision 不变;`games.spec` 结束后桌聊比分不变;NPC 只用本席投影做决定 | 角色“只取本席可见候选”在 mock 里成立;**JEV 未接通**(MOCK) | +| A17 | PASS(mock) | `games.spec`:正常结果后可桌聊、比分与 revision 不变;原桌“再来一局”同名同席位、新会话 ID、旧结果仍在最近;`extra.spec`:放弃本局 → “已放弃 · 未完成(只读)”、不编造胜者、最近里标“已放弃(未完成)”;原桌再开沿用同一批席位身份,从发现/准备页开局则为临时陪玩生成新身份(mock 服务端每局随机 identity) | “新身份”只在 mock 服务端代码与 rematch 报告层面确认,没有单独的界面断言 | +| A18 | PARTIAL | `live2d.spec` 五位角色逐个:角色页放大后的 1× 大脸截图、30 秒 5 Hz 采样(帧差、首/中/尾缩略图)、happy/surprised/neutral 表情由运行时回报、通道清单无眼球通道(Nora gaze OFF,其他角色也不写眼球);`edge.spec` 停止后表情回到“平静” | 采样 151–152 帧/30 秒(5.0 fps),94.0–99.3% 的相邻采样有变化;软件 GL 下平均帧耗时 40–93 ms(上一轮是 24–53 ms;同一构建上开/关房间背景、滤镜和底部渐暗的 A/B 对照没有可分辨的差别,差异来自软件 GL 的波动,见 `live2d-backdrop-ab.json`);**“自然”“可辨”需要人工目检**,自动化只证明持续在动、无冻结、表情状态切换正确 | +| A19 | PARTIAL | `extra.spec`:模型缺失 → 同一角色静态图 + “动态形象不可用”,图片缺失 → 中性占位,不用别的角色顶替,聊天照常;`art.spec`:图片缺失时生成的房间背景、封面、插画、头像同样安静消失(无破图、品牌回到字母);私聊页写明“无语音服务:文字为准,嘴型只跟随明确标注的演示音轨” | **口型只接了本地演示音轨,未做“跟实际音频/静默闭嘴”的自动化测量**;TTS 为 MOCK/未接通 | +| A20 | PASS(local) | `live2d.spec` 生命周期:五角色来回切 10 次只剩 1 个活跃运行时、离开后 0 个且创建数=销毁数;放大往返 4 次;隐藏标签页不绘制、恢复后继续;离屏暂停、回到屏幕继续;牌桌侧栏开关 6 次无残留;WebGL context loss 后恢复并继续出帧;帧耗时与 JS 堆记录在 `live2d-lifecycle.json` | 内存只记录了 JS 堆,没有 GPU 显存;软件 GL | +| A21 | PASS(mock) | `story-create.spec`:创作空态 → 选类型 → 命名 → 编辑(模板改稿可撤销)→ 必填项不全不能试演 → 隔离试演可聊 → 改规则后提示“需要重新试演” → 登录后保存 v1 → 明确开启分享 → 用 v1 加好友聊天;试演内容不进入正式好友会话 | “AI 改稿”是本地模板,不是模型 | +| A22 | PASS(local) | `layout-a11y.spec`:1024/1280/1440/1920 × 中/英 × 5 个页面共 40 张截图,横向溢出 0;200% 缩放(CSS 640×400、720×450 @2x)无横向溢出、牌桌按钮可达;跳转链接、Tab 顺序进入主内容、Esc 逐层关闭并归还焦点、Shift+Tab 反向;系统“减少动态”下动画时长降到 0.00001 s 且仍可操作 | 移动端未做(交接要求单列后续);真实浏览器缩放用等效视口模拟 | +| A23 | PASS(local) | 干净目录:只复制源码与锁文件 → `npm ci` → `assets:sync`(按包内 SHA256SUMS 逐个核对 186 个文件)→ `assets:check` → `assets:refs`(代码引用 37 处无断链)→ `assets:generated`(46 个生成素材副本 sha256 与代码引用全部一致)→ `check` → `build` → `dev` 首页 200;`docs/ASSET_LOCK.json` 每个交接包素材有相对路径、字节数、sha256、权利标签,`docs/GENERATED_ASSETS.json` 每个生成素材有原图与副本 sha256、prompt、权利标签 | — | +| A24 | PASS(local) | README “Mock 与真实服务”表 + `src/api/adapter.ts` 的 `REAL_ADAPTER_GAPS`(账号页也显示);交接包的候选游戏封面未作默认(游戏海报改用用户提供的生成封面,来源与权利见 `docs/GENERATED_ASSETS.md`,一行可切回交接包海报);账号页列出两类素材来源与权利标签;交付包秘密扫描(.env、私钥、token 样式、原机绝对路径、cookie/session 文件)结果见 `SECRET_SCAN.txt` | — | + +## 最终验证中发现并已修复的问题 + +新增的边界测试在最后一轮里抓到了下面几处真实缺陷,均已修复并重新验证: + +1. **Live2D 共享渲染循环会整体停摆**:某一帧抛异常(例如绘制恰好撞上 WebGL context loss)时,异常冒出单一 RAF 循环,之后所有舞台都不再出帧(context-loss 用例失败)。现在每个运行时的帧单独 try/catch、绘制前检查 `isContextLost()`,连续失败才降级为静态图。 +2. **私聊发出后立刻离开页面,消息会凭空消失**:卸载时把已经发出的发送请求也中止了。现在离开只中止展示用的流,已发出的请求照常完成;回来时看到一轮已提交的回复(`edge.spec`)。 +3. **离开页面时草稿防抖写入被丢弃**:回来会冒出更早的旧草稿。现在卸载时立即写入最新草稿。 +4. **双击发送**:发送按钮原位变成“停止”,双击的第二下会把刚发出的消息停掉。现在忽略双击的第二击(`event.detail > 1`),单独再点一次仍可停止。 +5. **(仅测试驱动)CABO 整局脚本会卡死**:点“继续”后按钮在 hold 请求返回前仍带着 `continue-ai`,脚本可能等待一个已经变成“暂停 AI”的按钮。已改为不阻塞的恢复步骤并给所有动作加 20 秒超时;应用代码未改。 + +已知小问题(未修):牌桌“继续”按钮的 hold 请求如果失败,没有错误提示(界面保持暂停,可再点一次)。 + +## 生成素材接入(2026-10-14,batch-01) + +- **收到与核对**:Dropbox `generated-assets/batch-01/` 共 45 个素材位、59 张 PNG;逐张与批次自带的 `SHA256SUMS.json`、`manifest.json` 一致;逐张目检:无文字/数字/水印/人物,透明图确有 alpha。工具记录为 GPT 内置图片工具,模型名未披露。 +- **选用**:A/B 两版一律选 `__a`;生成 46 个网页副本(只缩放、转 WebP/PNG;浏览器图标把圆角外的纯黑角改透明),放在 `public/generated/batch-01/` 随源码提交;`docs/GENERATED_ASSETS.json` 记录每张原图与副本的 sha256、实际 prompt、QA 结论、未用候选,`docs/GENERATED_ASSETS.md` 是人读版。重新从原图生成得到逐字节相同的 46 个文件。 +- **权利标签**:“用户经 GPT 生成并提供;商用/再分发权未核实”(与交接包素材的“用户授权私人交接;商业/再分发权未核实”分开记录,账号页“素材来源”里也写明)。 +- **接到哪里**: + - 故事:竖封面(发现卡 4:5、准备页 3:4、最近/继续/一起玩缩略图)、横封面(详情页 4:3);8 张逐拍场景——会话状态新增 `scene_position`(按审过的 5:1 横条定位)与 `scene_label`(地点名,中英文),开场用第一拍的场景,每次提交的节拍切换,《末班车之后》最后一拍之后的自由行动到“站台 · 天亮”;回退时场景随正文一起退回;旧存档里的旧场景键按回合重新推出。 + - 角色舞台:五位角色各自的房间 + 通用房间(自创角色),在 Live2D 画布与静态图下面一层;角色页、私聊、牌桌侧栏、故事左栏、发现页角色卡、好友列表头像底都用同一组件。 + - 游戏:三张封面成为默认海报(发现 1:1、准备页 3:4、一起玩与最近缩略图);悬停预览短片仍是交接包的;交接包 480×480 海报仍同步在本地,`src/mock/catalog.ts` 改一行即可切回。 + - 状态:好友(未登录 / 没有好友)、最近、创作、找不到、加载失败(发现页、各详情页)、需要登录(含登录弹窗);新建作品弹窗的两张类型插画。 + - 作品封面:新作品默认得到一张(按作品 id 稳定选择),编辑页可换;草稿只存 id,服务端只接受 6 个预设 id。 + - Persona 头像:账号页可选 8 个物件头像或不用;只存 id、服务端只接受预设 id、不传时保留原值;显示在导航与自己的牌桌/准备页席位。 + - 品牌标记(导航、首次加载)与浏览器 / 主屏图标。 +- **为接入做的视觉调整**:静态备用图从“完整缩放居中”改为“从顶部铺满、底部渐隐”,舞台底部加一层渐暗——原来的剪影下沿放到房间背景前会很突兀(Live2D 正常时不受影响,`live2d.spec` 只读画布像素)。 +- **验证**:单元新增 8 项(场景随节拍、旧场景键、记录缩略图、头像/封面只收预设 id、舞台背景选择与关闭、缺图路径改写、加载失败即消失);新增 `art.spec` 3 项(用到的生成图在视口内全部真实加载、场景顺序、头像与封面选择刷新后仍在并出现在别处、“图片素材缺失”下 7 个页面无破图且不显示任何生成图);`story-create.spec` 增加场景切换/回退与自创角色用通用背景的断言;`npm run assets:generated` 核对 46 个副本与代码引用。 + +## 视觉对照(新旧截图) + +`evidence/screenshots/compare/*-pair.jpg` 把交接包 `baseline/screenshots` 的旧截图与新界面同页面、同语言(英文)、同视口尺寸并排放置。**这不是“同一局面”的严格对照**:旧截图是原产品历史合成 QA 的目录/牌桌(玩家数与牌面都不同),新界面是本地 mock 数据,只能用于对比信息结构与布局,不能证明视觉连续性。 + +## 未完成 / 需要人工 + +1. 真实中文输入法(macOS / Windows)人工走查(A04)。 +2. 口型与实际音频的同步测量、静默闭嘴(A19);TTS、viseme 服务未接通。 +3. 故事内“纠错”(A10)。 +4. 全部真实服务(账号、目录、好友、私聊 Luna、故事叙事、桌游 JEV、创作、TTS)——只有 mock 与真实适配器开关,没有任何真实接入。 +5. 真实 GPU、真实显示器下的 Live2D 观感与帧率;“动作是否自然”的人工判断(A18)。 +6. 同局面的新旧视觉对照(需要原产品可运行并导出同一局面)。 +7. 移动端专项。 +8. 独立验收(本文件是作者自查)。 +9. 生成素材的权利:用户经 GPT 生成并提供,商用/再分发权未核实,模型名未披露;只适合这次私人演示。自创角色仍没有立绘(中性占位),自创故事没有场景图;生成图在真实显示器上的观感需要你目检。 diff --git a/everwith-claude-frontend/README.md b/everwith-claude-frontend/README.md new file mode 100644 index 00000000..a0cbcb76 --- /dev/null +++ b/everwith-claude-frontend/README.md @@ -0,0 +1,147 @@ +# Everwith · Claude 独立重建前端(本地演示) + +依据 Dropbox `/Everwith_Claude_Frontend_Handoff_2026-10-13`(00–10 文档、`reference/`、`assets/`、`verification/`)新建的**独立、可运行**桌面网页前端。不修改原产品,不读取原项目 `.env`/会话/数据库,不连接任何真实服务。 + +- **完整功能面**:发现(全部/角色/世界/游戏、搜索、空态/失败态区分)、好友、角色详情(明确加好友+命名、同源多好友)、一对一私聊(流式段落、停止、可重试、结果未知查询、晚到旧流丢弃、回退/纠错/重命名/重置/移除)、故事详情→准备→游玩(场景/文字、已知状态、继续剧情、回退、局中加好友)、三款桌游(Royale / Splendor / CABO:准备→明确开始→完整对局→结果→原桌再开)、最近(逐局记录、继续/回看/归档/删除)、创作(类型→命名→编辑→AI 模板改稿可撤销→隔离试演→保存版本→显式分享→用版本加好友)、账号(邮箱密码、Persona、中英切换、减少动态)。 +- **五位角色 Live2D**:Lena / Arthur / Marcus / Nora / Iris,Cubism Core 6.0.1 + Framework(来自交接包),冻结的 verified frame writer 单写者、表情眨眼换脸、口型只跟演示音轨、Nora 不写眼球(gaze OFF)、离屏/后台暂停、销毁释放、context loss 恢复。 +- **生成素材(GPT batch v2-batch-01)**:故事封面与逐拍场景、五位角色各自的房间背景(Live2D 后面)、三款游戏封面、空/错/登录状态插画、Persona 头像、作品封面、品牌标记与图标。来源、哈希与 prompt 见 `docs/GENERATED_ASSETS.md`;权利标签“用户经 GPT 生成并提供;商用/再分发权未核实”。 +- **全部服务都是本地 mock**:界面与 API 之间只有一个适配层(`src/api/adapter.ts`)。默认在浏览器内运行模拟服务端(数据存 `localStorage`),所有页面带“本地演示”标识,桌游标“规则模拟陪玩”,故事标“本地模拟叙事”。**没有任何真实 Luna/JEV/TTS/账号/存储接入。** + +## 版本 + +| 项 | 版本 | +|---|---| +| Node.js | ≥ 20.19(验证于 22.22.0),npm 10.9 | +| React / React DOM | 19.2.0 | +| Vite / @vitejs/plugin-react | 7.1.12 / 5.1.0 | +| TypeScript | 5.9.3(strict) | +| Vitest / jsdom / Testing Library | 4.0.6 / 27.0.1 / 16.3.0 | +| Playwright | 1.56.0(Chromium 141,revision 1194) | +| 图标 | lucide-react 0.545.0 | + +所有版本在 `package.json` 中精确锁定,`package-lock.json` 已提交。 + +## 干净环境启动 + +```bash +cd everwith-claude-frontend +npm ci # 安装锁定依赖 + +# 1) 从交接包按字节复制素材(不下载、不转码),并用包内 SHA256SUMS 逐个核对 +npm run assets:sync -- --from "/path/to/Everwith_Claude_Frontend_Handoff_2026-10-13" +# 也可用环境变量:EVERWITH_HANDOFF_DIR=/path/to/handoff npm run assets:sync +npm run assets:check # 只重新计算 hash,不复制 +npm run assets:refs # 检查代码里写死的素材路径没有断链 +npm run assets:generated # 核对随源码提交的生成素材(public/generated)sha256 与引用 + +# 2) 开发 +npm run dev # http://127.0.0.1:5173 + +# 3) 检查与构建 +npm run check # tsc --noEmit + 全部单元测试 +npm run rules:check # 三款冻结引擎完整对局 + 投影不泄密检查 +npm run build # tsc + vite build → dist/ +npm run preview # http://127.0.0.1:4173(生产构建) + +# 4) 端到端(Playwright,自动 build + preview) +npx playwright install chromium # 本机首次需要 +npm run test:e2e # 全部 29 项,约 45 分钟(三局完整对局正常速度录屏约 22 分钟) +npm run test:e2e:quick # 跳过 @long(完整对局、Live2D 30 秒采样、四宽度矩阵) +``` + +`assets:sync` 会生成 `public/handoff/`(≈135 MB,186 个文件)和 `src/vendor/cubism-framework/`(Cubism Framework TypeScript 源码,由 Vite 编译)。这两个目录**不随源码打包**(`.gitignore` 已排除),必须由上面的命令从交接包复制。若素材缺失,应用会退化为同一角色静态图/中性占位/文字,并明确显示“动态形象不可用”,聊天与规则仍可用。 + +生成素材的网页副本(`public/generated/batch-01/`,46 个文件,约 3.9 MB)**随源码提交**,不需要同步。它们是 Dropbox `generated-assets/batch-01/` 原图的缩放副本;想从原图重新生成:`python3 -I scripts/derive-generated.py --from "<…>/generated-assets/batch-01"`(需要 Pillow,可选)。 + +## 目录 + +``` +src/ + api/ 适配层(mock / real)、类型化客户端、响应校验(拒绝含 truth/rng/seed 的桌游投影) + contracts/ 从 reference/contracts 移植的协议解析器(private-public-segments.v1 等) + mock/ 本地模拟服务端:账号、目录、好友、私聊(NDJSON 流)、故事、桌游权威状态、记录、创作、场景开关 + games/engines/ 三款游戏的冻结纯引擎(与交接包逐字节一致,见 PROVENANCE.json)+ 新增 .d.ts + live2d/ 运行时、导演(单 RAF)、表演器、冻结 frame writer / Iris carrier 单位、舞台组件(静态图下面一层是角色房间背景) + art/ 生成素材登记(背景、状态插画、作品封面与 Persona 头像的 id → 路径;只做呈现,不参与身份) + chat/ 私聊控制器(generation/receipt/retry/lookup)、IME 安全输入框、安全富文本 + features/ 各页面(discover / friends / character / chat / story / games / recent / create / account / mock) + styles/ 设计令牌与页面样式(无 CSS 框架) +scripts/ assets:sync / assets:check / assets:refs / assets:generated(check-generated.mjs)/ derive-generated.py(可选,从原图生成网页副本) +public/generated/ 生成素材网页副本(随源码提交) +tests/ Vitest 单元与集成测试 +e2e/ Playwright 端到端(证据写入 evidence/) +evidence/ 截图、录屏、Live2D 采样、JSON 报告(本次运行产物) +docs/ASSET_LOCK.json 复制素材清单(相对路径、字节数、sha256、权利标签) +docs/IMAGE_GENERATION_REQUESTS.md 图片生成需求单 v2(已交付并接入) +docs/GENERATED_ASSETS.md / .json 生成素材接入记录:每张用在哪里、原图与副本 sha256、实际 prompt、未用候选 +``` + +## Mock 与真实服务 + +| 层 | 当前状态 | +|---|---| +| 账号 | **MOCK**:邮箱+密码(加盐 SHA-256,仅避免明文存储,不是生产认证),游客→登录后安全归并,无找回 | +| 目录/发现 | **MOCK**:5 位官方角色、2 个故事、3 款游戏;无伪热度 | +| 好友 | **MOCK**:幂等 key、epoch/重置/移除、来源版本 | +| 私聊回复 | **MOCK**:本地固定回复片段(`simulated_fixed_reply`),流式分段帧、可停止/重试/晚到旧流 | +| 故事叙事 | **MOCK**:固定剧情节拍,整轮校验后原子提交(“本地模拟叙事”) | +| 桌游 NPC | **MOCK**:冻结引擎自带的固定策略,只用本席投影(“规则模拟陪玩”);**JEV 未接通** | +| 创作“AI 改稿” | **MOCK**:本地模板(不调用模型),可撤销 | +| 语音/口型 | 仅本地演示音轨驱动口型;**无 TTS/viseme 服务** | + +真实适配器开关(只写名称,不提供任何密钥): +- `VITE_EVERWITH_ADAPTER=real` 选择网络传输; +- 同时还必须设置 `VITE_EVERWITH_ALLOW_NETWORK=1` 才会真正发请求;`VITE_EVERWITH_API_BASE` 为可选前缀; +- 未开启网络时所有请求返回 `real_adapter_not_enabled` 并在页面顶部显示红色提示,**不会静默回落到 mock**。 + +接真实服务前的已知缺口(同时显示在“账号与设置”页):账号形状与 CSRF 引导、`/discover` 等候选端点、好友 rename/reset/remove 与 origin 绑定、私聊 `{operation, admission}`/receipt/public-stream、全部 story-sessions、game-sessions 与 JEV 决策接缝、works/previews、TTS。详见 `src/api/adapter.ts` 的 `REAL_ADAPTER_GAPS`。 + +### 演示场景开关 + +左侧导航“演示控制”(桌游牌桌内为工具栏烧瓶按钮)可打开以下场景,用来走异常分支(一次性场景被下一个相关请求消费): + +`chat.retryable_failure`、`chat.commit_before_cancel`、`chat.unknown_result`、`chat.late_old_stream`、`chat.stale_head`、`chat.friend_reset_elsewhere`、`auth.expired`、`auth.forbidden`、`net.slow`、`discover.error`、`discover.append_error`、`live2d.model_missing`、`assets.missing`、`game.text_failed`、`game.unknown_start`、`story.retryable_failure`。面板里还可以导入一个 Splendor v1 旧存档样例(按 v1 原规则继续)和清空本地数据。 + +## 素材与权利 + +两类来源,分开记录,界面“账号与设置 → 素材来源”里也写明: + +**交接包素材** +- **逐字节复制**并按包内 `verification/SHA256SUMS.txt` 核对;未生成、未缩放、未重新编码。清单见 `docs/ASSET_LOCK.json`(每项含相对路径、字节数、sha256、权利标签)。 +- 权利标签:**“用户授权私人交接;商业/再分发权未核实”**。仅用于本次私人交接与本地演示,不得公开发布或商用。 +- 用途:角色头像与 feed 图(`assets/companions/portraits|feed`)、游戏悬停预览视频(`game-previews/v1`)、Live2D 五个冻结运行时(`assets/live2d`)、Cubism Core/Framework(`assets/runtime/cubism`,Live2D 许可文本随附)、Royale 赌场插画 atlas r7 与钞票纹样、Splendor 文艺复兴卡面/贵族/牌背/宝石/台布、CABO 14 张点数插画与牌背(manifest 校验后使用)。交接包的候选游戏封面没有用作默认。 + +**生成素材(GPT batch v2-batch-01,用户经 GPT 生成并提供)** +- 45 个素材位 / 59 张图,按批次自带的 `SHA256SUMS.json`、`manifest.json` 核对后,只做缩放和格式转换(WebP/PNG);A/B 两版一律选 a。逐张记录见 `docs/GENERATED_ASSETS.md`(人读)与 `.json`(含原图/副本 sha256、实际 prompt、QA 结论、未用候选)。 +- 权利标签:**“用户经 GPT 生成并提供;商用/再分发权未核实”**。同样只用于本次私人演示。 +- 用途:故事竖/横封面与 8 张逐拍场景(场景横幅按审过的 5:1 区域定位,并显示地点名)、五位角色各自的房间背景 + 通用背景(Live2D 与静态图下面一层,缺图时回到原来的渐变)、三款游戏封面(默认海报;交接包原海报仍在,`src/mock/catalog.ts` 一行切回)、六张状态插画、两张新建类型插画、六张作品封面(作品草稿只存 id)、八个 Persona 头像(物件,不是人像,只存 id)、品牌标记与浏览器/主屏图标。 +- “图片素材缺失”场景同样覆盖生成素材:全部安静消失、显示原来的渐变/字母/图标,不出现破图,也不拿别的角色顶替。 + +规则数字、骰点、钞票金额、筹码、卡牌数字与 6/9 标记、能力文字、场景地点名全部由代码绘制,图片只提供插画。 + +## 冻结来源(未改动的部分) + +- `src/games/engines/**/*.js|ts`:Royale `engine.ts`、Splendor `data.js / engine.js / rules.js / rules-v2.js / npc.js`、CABO `engine.js / core.js / rule-evidence.js`——与 `reference/games` 逐字节一致(`npm run rules:check` 会重新计算 hash)。 +- `src/live2d/verified-frame-writer.ts`、`carrier-parameter-units.ts`:逐字移植。 +- `src/contracts/public-events.ts`、`dialogue-expression-client.ts`:逐字移植;其余合约文件只改了 import 路径。 + +## 测试与证据 + +- `npm run check`:TypeScript strict(含 e2e 目录)+ Vitest 54 项: + - `engines.test`:冻结引擎与交接包逐字节一致;三款游戏 2–5 人完整对局、Splendor v1 旧存档按原规则完成、未知规则档案拒绝升级、CABO provisional 标注; + - `cabo-rules.test`:射月、失败 CABO +5、叫牌者并列最低获胜、恰好 100 → 50 一次、>100 终局; + - `mock-api.test`:好友幂等/同源多人/epoch 重置、私聊控制器(提交一次、并发发送拒绝、可重试同一操作、结果未知查原 key、停止输给提交、旧 run 晚到帧、真实取消)、开局幂等与查询、一个好友一个席位、故事选角限制、入局好友不带私聊内容、好友重置后故事只读、创作草稿 base revision 与分享前置;生成素材接线(故事封面、逐拍场景与开放模式结尾地点、旧存档场景键回推、记录缩略图、Persona 头像只收预设 id、作品封面默认值与校验); + - `richtext-router.test`:`*动作*` 与奇偶反斜杠转义、HTML 不执行、换行保留;路由与旧深链; + - `components.test`:IME 合成事件不发送、选区加星号、双击发送只一次且第二击不触发“停止”、CABO/Splendor 不渲染未知值;舞台背景按角色选择(自创角色用通用背景、可关闭)、缺图场景路径改写、背景/插画加载失败时直接消失。 +- `npx playwright test`(生产构建 + Chromium;Linux 下 SwiftShader 软件 WebGL)——本次 29/29 通过: + - `art.spec`:生成素材在用到的地方都真实加载(视口内逐张检查 naturalWidth)、故事场景随提交的节拍切换并在开放模式到达结尾地点、记录缩略图、状态插画、新建类型插画、图标;Persona 头像与作品封面选择后刷新仍在并出现在导航/席位/作品卡;“图片素材缺失”下七个页面无破图、无生成图、品牌回到字母。 + - `core.spec`:发现页、搜索空态 vs 加载失败、深链与 Back;加好友中途登录、名字恢复、私聊发送/草稿刷新保留/停止/可重试/结果未知;好友“一起玩”进入桌游准备。 + - `story-create.spec`:故事开局、整轮提交、继续剧情不伪造玩家发言(场景随节拍切换)、回退恢复草稿(场景一起退回)、可重试失败、局中把当前人物加为好友;创作全流程(试演不进入正式会话;自创角色私聊用通用背景)。 + - `extra.spec`:牌友原位转好友、登录取消不丢位置、放弃本局只读;最近“继续”回到原局、回访落到好友页、Splendor v1 旧存档;模型/图片缺失降级;Shift+Tab。 + - `edge.spec`:双击发送、停止输给提交、停止后表情回中性、旧 run 晚到帧、另一窗口先提交、好友在别处重置、生成中离开、登录过期;阅读旧消息不被拉底与阅读位置/草稿/选区保留;开局回执丢失、打开不存在的局、桌聊台词失败;拒绝全屏/两次 Esc 后牌桌仍可操作。 + - `layout-a11y.spec`:1024/1280/1440/1920 × 中/英无横向溢出(@long)、200% 缩放、跳转链接/Tab/Esc/焦点归还、减少动态、CDP 合成 IME。 + - `live2d.spec`(@long):五位角色 1× 大脸、≥4 fps 采样 30 秒、表情、通道清单无眼球写入;切换/放大/隐藏标签页/离屏/牌桌侧栏的资源释放与暂停、WebGL context loss 恢复。 + - `games.spec`(@long):Royale 4 人三轮、Splendor 4 人 15 分补齐轮、CABO 4 人累计 >100,**正常速度录屏**,局中检查暂停/侧栏/沉浸/全屏/刷新后同一局继续、结束后可聊且锁分、原桌再开。 +- 证据目录 `evidence/`:`screenshots/`(含 `matrix/` 40 张、`zoom200/`、`compare/` 新旧并排)、`videos/`(三局完整录屏 .webm)、`live2d/<角色>/`(大脸、表情、采样帧、帧差)、`reports/*.json`。 + +所有截图与录屏都来自云端容器里的 headless Chromium(软件 WebGL、无声音、无真实输入法),不是你本机的真实浏览器;验收逐项状态与未完成项见 `ACCEPTANCE.md`。 diff --git a/everwith-claude-frontend/SECRET_SCAN.txt b/everwith-claude-frontend/SECRET_SCAN.txt new file mode 100644 index 00000000..850eb211 --- /dev/null +++ b/everwith-claude-frontend/SECRET_SCAN.txt @@ -0,0 +1,16 @@ +# Secret scan 2026-10-14T08:15:50Z over the source tree and evidence/reports (node_modules, handoff assets, vendor excluded) +## env / key / session files +(end) +## token-like strings +(end) +## absolute paths from another machine +(end) +## password literals (expected: synthetic test fixtures only) +NOTE: ./src/app/i18n.ts:40: 'auth.title.login': '登录', 'auth.title.register': '注册', 'auth.email': '邮箱', 'auth.password': '密码', 'auth.passwordHint': '至少 8 位。本地演示不提供密码找回。', +NOTE: ./src/app/i18n.ts:43: 'auth.error.invalid_credentials': '邮箱或密码不对。', 'auth.error.email_taken': '这个邮箱已注册,请直接登录。', 'auth.error.weak_password': '密码至少 8 位。', 'auth.error.invalid_email': '邮箱格式不对。', +NOTE: ./src/app/i18n.ts:167: 'auth.title.login': 'Sign in', 'auth.title.register': 'Create account', 'auth.email': 'Email', 'auth.password': 'Password', 'auth.passwordHint': 'At least 8 characters. The local demo has no password recovery.', +NOTE: ./src/app/i18n.ts:170: 'auth.error.invalid_credentials': 'Wrong email or password.', 'auth.error.email_taken': 'That email is registered; sign in instead.', 'auth.error.weak_password': 'Use at least 8 characters.', 'auth.error.invalid_email': 'That email looks invalid.', +NOTE: ./src/features/account/AuthDialog.tsx:49: setPassword(e.target.value)} required minLength={8} aria-invalid={!!error} aria-describedby="auth-pw-hint" /> +NOTE: ./tests/mock-api.test.ts:38: const account = await api.account.register('unit@example.com', 'password123') +NOTE: ./e2e/helpers.ts:42:export async function signUp(page: Page, email: string, password = 'password123') { +(end) diff --git a/everwith-claude-frontend/SOURCE_MANIFEST.txt b/everwith-claude-frontend/SOURCE_MANIFEST.txt new file mode 100644 index 00000000..1c9fc60c --- /dev/null +++ b/everwith-claude-frontend/SOURCE_MANIFEST.txt @@ -0,0 +1,183 @@ +# everwith-claude-frontend source manifest +# app code sha256 (everything below except *.md, tests/, e2e/, playwright.config.ts): fde68002697dd16e7292b4c6b1119b232fa57dd09a665c381a850102ea0dc670 (158 files) +# test code sha256 (tests/, e2e/, playwright.config.ts): cd1bc7b1b68a465aab785f10326d99acf376ff6b6da7bc87900c88a530f81fcd (16 files) +# full tree sha256 (every line below): 705319dac54573e2cac0e9d52eb8fcb236802a37d18234339db04fa05112ecbe (178 files) +# recompute: sha256sum each listed file in this order, keep the matching lines, then sha256sum that list (lines exactly as printed by sha256sum) +95011726900d8e193227e00d2aa5bd8cf1fac8f5873df460f1443084df9dc1a1 ./.gitignore +ef253cfb3971b9a3fe1fde403d28911a475e01f94e46955421ab372b969adfba ./ACCEPTANCE.md +e24ce4efbcf26cecfd483972c91dae89e9ad8cd87281d150c2c872033e5133bf ./README.md +eb287dd4a62b3a27c94a0e9747644e7520119f225fc21085c6b5e22d80630716 ./docs/ASSET_LOCK.json +fbff92bce2bc19278d4ee0192fa35d51dcaa62d3c475325e35c81d9f0af5feca ./docs/GENERATED_ASSETS.json +380940633011b7fd6d159980065c894bddb4b64f02701831e6978929533067d9 ./docs/GENERATED_ASSETS.md +04070432822fa92e2643792da113e1f3674255d6195a44acb127b51207a5c56d ./docs/IMAGE_GENERATION_REQUESTS.md +14da91d28dacd2dd3e184414135ca7e7cb1477f4bc63a79c308d371a939c3edd ./e2e/art.spec.ts +4d34cdb869772c6c97ec93c089cdf97573d84c8848898f142f00e6bb155285b5 ./e2e/core.spec.ts +7cbebfb8847ebbd6707c49fcf3b6303fbde3b0946db026895d33429aefeba4b1 ./e2e/edge.spec.ts +0144f079a332a9c9f2097cd0a28246131643af21356f0d231a54e97d347f8f52 ./e2e/extra.spec.ts +b98fbd22eafb8a7aa21add1e42c63c87f684bc98bef889c93b0463b785f79440 ./e2e/games.spec.ts +41cf836a0615de6278bbe5638ac004d1c7a7da05e5e52b0bec713331a44751d0 ./e2e/helpers.ts +8acaf7a9f26edb9311d65a1f5c478582feaa1181f75a0b1ccea6eb6e402f195e ./e2e/layout-a11y.spec.ts +cba0fabdbb1340b7b39faa63269f7e47d5c760b837f6fa1d8ad51a858298d4dd ./e2e/live2d.spec.ts +5087235389410e5ebf2c9cc8d0017bd573688612108ceb9c7c67b824e8e8c52b ./e2e/story-create.spec.ts +3fd03cf4959f3d700c2c627cfd1272597ced0f9f5e1c355af8501c77cfa6f463 ./index.html +c147d4a40147cbc253c172602e680542eb7bc99a55ecf38b04a918bce8963662 ./package-lock.json +d44f64a6ada1372c4e8b7e77bbda01890b309dd4d630df9abed286e8ade17d00 ./package.json +44db26471f9e7f1de571d10f2cbf83aceca6bdaff28ef3ae9ca309bdad956121 ./playwright.config.ts +4fa396920abe9214b7d85d81efb3f0d80ff8469e934d38e80ded53ce3d5b142f ./public/generated/batch-01/apple-touch-icon.png +48d7f68eba34ff7669c2d37ed3db7652e107e302f16c56eeb49d6af9635a8e44 ./public/generated/batch-01/backdrop-arthur.webp +d71aaceab1193400b3c8e19d0faf31d763067011370a776e582429f61b6b6599 ./public/generated/batch-01/backdrop-generic.webp +ab9a7971e7d6ab87e48c5bf4f787eac39280e4f0b863630b902a39108cbd7ca4 ./public/generated/batch-01/backdrop-iris.webp +02a95515edcf6c409bc1488784621912d811ea319441bd57aafaaa3b54d25598 ./public/generated/batch-01/backdrop-lena.webp +0aaef6240f727b0d3856defe5b55077e67ec3fdc736f9a6ae86846130b5466a8 ./public/generated/batch-01/backdrop-marcus.webp +62e614f596f61a7562facecdf8676372fe905f3e9917e80947b9dd4153aa24f0 ./public/generated/batch-01/backdrop-nora.webp +ddd3f61570975c9fea7f9484c432bb069dd06f8f25d7d384dbea668961ac6052 ./public/generated/batch-01/brand-emblem.png +3d3d491407d7479be57670eb1864950a9587bbc5b3b8bc39afc0412d7971209e ./public/generated/batch-01/create-type-character.webp +77212795bda011a04a827d22f93084b907e48163db6ba5f694897ac0be434ae7 ./public/generated/batch-01/create-type-story.webp +026b9b8aba3c2f1208818f2c5e2c0463284e412bb95a26d9fdac1d4c90c5c1a4 ./public/generated/batch-01/empty-create.webp +22d64e1ea99ad56f754be9ff909fdf174741b55a25ba92d9ba44fa4ed7a7a243 ./public/generated/batch-01/empty-friends.webp +6955a081460764fc59e08daa146267628f9acd9743b153119abcb80c799992d9 ./public/generated/batch-01/empty-recent.webp +ec5bf9b048dc978ad5546945a8cb84b3807f8c0887739a3c74e7f96db4f3678c ./public/generated/batch-01/favicon-64.png +c9a4921c7a373b4a5f5f7a8167c8d3ff940228763012127a51cb2f2c234e79d2 ./public/generated/batch-01/game-cabo-cover.webp +eb96c3a6bcc8723df64499ff8d5fb6fcd1a3e13f401bc6f000e4a2eea46ff7ff ./public/generated/batch-01/game-royale-cover.webp +e164cd165c53bab2c6a4266b7e2235892d30ffc354e547919414445bb46d472c ./public/generated/batch-01/game-splendor-cover.webp +d33dc1ee03a557e43cbc7863b31dd03ac39db9621d479863e81d6f47a10fbf35 ./public/generated/batch-01/persona-avatar-01.webp +7898a99dad8c4900492cac2a47e90bcc8dea163cf805cd8a6ec9deb02eed2104 ./public/generated/batch-01/persona-avatar-02.webp +0e24b3ed31d63eb842029ba75345215c8d28fcda749db3c680f7621f26b12702 ./public/generated/batch-01/persona-avatar-03.webp +3f042bf55e3d4ccc4d6a01b3b57d6cd846680bae58872323218898c0d9ea6ce0 ./public/generated/batch-01/persona-avatar-04.webp +363493222a22052642deea71fa9d100b80f5112fb0a8ada523c26a36e03ba4bb ./public/generated/batch-01/persona-avatar-05.webp +81db2e8f4e29b5dd96a56219c24b6d69090657158c7366a47ff459e08077fa37 ./public/generated/batch-01/persona-avatar-06.webp +707439c27ab4fa24631239809260639841008d8d5d048114f586348b6e87eeb1 ./public/generated/batch-01/persona-avatar-07.webp +a6dc2af779adf5ec00cc353efca8ac2bb382cdf0e3f22009af17f53eaa789bf1 ./public/generated/batch-01/persona-avatar-08.webp +978c18c4d84aa945f6a036029581b01af932e4b63fc509e09cfd32e166cc7420 ./public/generated/batch-01/state-load-error.webp +c50e6a13caa826e9f359d5fcbd2437e0f7c988593ab47da5b498c5cb55de313a ./public/generated/batch-01/state-not-found.webp +091668c040c486562cd3e27f3e8402af79b7bd26f5d65a66d62a11964d6bcac2 ./public/generated/batch-01/state-signin.webp +8db4e77c2c9dadd8407f5210b0ee35536a91c12f92d06b1839ea48d244a003b5 ./public/generated/batch-01/story-last-train-cover-tall.webp +4e8010033d7d34d5463745a859f7fb837720d50784c0323a2cb97d75246a691a ./public/generated/batch-01/story-last-train-cover-wide.webp +62ae7c62ab0d5774503a349f5273484c7c65a5d743a6948d20958796256e560f ./public/generated/batch-01/story-last-train-scene-corner.webp +2319267b88c1bd89e11ecf1bf58039c3b3947ca21fb4664368c81eff3278843b ./public/generated/batch-01/story-last-train-scene-duty-room.webp +9024a2db5a76eb389ebcd696540561ccd8c9d07cb263b13ef1cec5dac1fc73d8 ./public/generated/batch-01/story-last-train-scene-platform-dawn.webp +47dfaf081d85d488ff7384a129030aa3d701bbcbec6c812dfa21d8ee92513dd3 ./public/generated/batch-01/story-last-train-scene-waiting-room.webp +98ea72d82f8bd32e52fb3e5dfaeaebe5dc3e9d4dc70cca9757aebeeea56adbf7 ./public/generated/batch-01/story-promise-rain-cover-tall.webp +db7f7bb8740cbf50d76948cedd742d22e48aa0ca495de3e8c492f96fea6b6aca ./public/generated/batch-01/story-promise-rain-cover-wide.webp +79fb86e539c5d205b81e8d8fd5fe43d74561143b920cc7f90682c4a4d2f7f338 ./public/generated/batch-01/story-promise-rain-scene-backstage.webp +fef4ed9f75ec56400ad6c8420525c1f0d3b6ac7c905a745f46533ef46c3a7216 ./public/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp +e052e77a4f4cb71eab8c30674915ddab09b037f6f91314360c7d42ea77de962d ./public/generated/batch-01/story-promise-rain-scene-entrance-rain.webp +56392a4b6445921d2155a6738ca722fac1136cae6ece6df32a9921d6c5bc9949 ./public/generated/batch-01/story-promise-rain-scene-office.webp +55654e81e0ee4c943f7285dfe56666c4877ce164bc19df8c55c7440ade6e5c6a ./public/generated/batch-01/work-cover-01.webp +7538eb83c469ef62c20e2b339eeed08fb58a96ecbad7c2dc074d26b6dd7c3fe8 ./public/generated/batch-01/work-cover-02.webp +a243867cace02b67670e77db280d5eeb86f8656bbfb72e0435ddbe2ab294e747 ./public/generated/batch-01/work-cover-03.webp +84b8984fd0dea6e2e6efe094fd07c38348d6a579264cddc406531a89a817b45a ./public/generated/batch-01/work-cover-04.webp +49d2766e6d30f191de196d58aab38f060a0c7c56c1ad09097c8cde5568830bbb ./public/generated/batch-01/work-cover-05.webp +af07293aceabc8203f8fc4cdc2a85b705a0cd68ac1795e5c9bd821f865a9ef8f ./public/generated/batch-01/work-cover-06.webp +37264be0375dbff8ffd911c724555fc31ff55c58e029fcca9fed9769ff1b0731 ./scripts/check-asset-refs.mjs +f9bc356d612e029a3312cce4afc949595eb7a75548e7299fda0fc3adfa66b75e ./scripts/check-generated.mjs +c0ad641b6e86edc0f69757dae895e7f81c4700efed8f06d54ec665c919fcf970 ./scripts/derive-generated.py +24414d93997942fbfaa1daa0aa99f4ebc43a11f948b751ae5fe8101fe125ce29 ./scripts/sync-assets.mjs +3334e1a571da193c2161ef6c3e7f626107a04925142647b927b5fe8eacff3dab ./src/api/adapter.ts +caa8bdbac81a62d823e0de30b3fa7a6c5c561bc0ddb88a7435529c8453ff15b4 ./src/api/client.ts +9c753f787bb929dc788ef66f220ce3f84eea376940f5c1dd59f5c82e31475da0 ./src/api/types.ts +9f0a8f0ed330818406ab5868b460650f0e89c720be06321839ce27b358dcaf7c ./src/app/App.tsx +0f435f084f0e696fa6f43d41209c90e12e6eda588125f1499e9e2658febcf95e ./src/app/i18n.ts +011b33c990ea227b159841ed8aa6e2d22d06e62e3b9356e5230599321753f5f0 ./src/app/router.ts +3dc400e01945aaa5b89cc424dc35b3269c877f0cba40359192f773fa035192ab ./src/app/store.ts +9057481163d80eda1364831009a8a44420568054cb3a3c3267cc1229b7346735 ./src/art/generated.ts +98223ca50039f04f07302aa0fb8d926e31c3b6e23fbb6c9e762ce72abccafeff ./src/chat/Composer.tsx +7da11681ebc4caa4722e180e5e93644d5b1918fe2992b6c158195adac0b8ffac ./src/chat/RichText.tsx +0f77ce8412c01570bb8305cd35d4bcc6f7d5cff896e268ffe2529dcadd203ba8 ./src/chat/presentation.ts +75f401e06ee6332534c79ba726972478f77c07a0a55447a706070e3755d1eaa5 ./src/chat/stream-controller.ts +a785f3afd827213e9450070cff0e8e3e686c586ee03e89810082f11120a9561b ./src/components/Art.tsx +420f0f4dc635f9f35cdd94dbfe726dc0e12d7346297de7099d518399317eb3e7 ./src/components/Dialog.tsx +9b34efa0c20e8a6609b676b08b1a26b7ac4e3ace10dd72a4b470afb604239889 ./src/components/Portrait.tsx +268f69a718d2f398c3654ef54bb0b25292014f3f096d62d5592c63525ab1b621 ./src/components/Toasts.tsx +45877cd8b7e64939dbc16740ef56f496dae0b7d7f0b0102086f40b9ece3fa9a9 ./src/components/use-modal-focus-return.ts +54369846fafe660fefe3ad4cabe65d07efd09fafb60f562ab8446e2544eaf9c8 ./src/contracts/chat-poll.ts +90d2099f9834f4bfb931afb63f5d4962071aeee377a3538fbfd4deb5df092007 ./src/contracts/chat-timing.ts +922b1b943e749226a01611ee257581cd5d877db6e7f6fe35044d55db6215ffe8 ./src/contracts/companion.ts +254717b817679bb41ba3c643fb1b4e8706d63842fb5ee05d07c980361d76acd4 ./src/contracts/dialogue-expression-client.ts +c641960b93aa16c13b9617411fef742c55107a0db2ab5f6fefb1472fb43a3d63 ./src/contracts/private-chat-client.ts +35a4de9dd566d88aa0bb8395fd4e1cce85014e6019e38db4ce6efcf13cfa14d4 ./src/contracts/private-chat-recovery.ts +dd88064063dff25c58217ebf77ba600de3cc527c0fcbe383d9902c408941272d ./src/contracts/public-events.ts +c13918eeb4bb81352c9ea9af87e5223206461a6a671e6439d2f42fdc718c5cab ./src/features/account/AccountPage.tsx +8a534a3928407e2023be1d52ba4908ef4fcc131f5047414883af7387b8e77a4f ./src/features/account/AuthDialog.tsx +1ad7839b79ffc8faa335908b336a6fdf718d216be2c4e345ea2be6a621e85522 ./src/features/character/CharacterPage.tsx +c8f136e84f108593bb1c06504f3b518f30e71499a5b751980efa2f34ce270824 ./src/features/chat/ChatPage.tsx +4474c72a6a32b144ae9a8e13fae2a9cec5a42b6e1ad9caa70b0e797f4f5edeaa ./src/features/create/CreatePage.tsx +5dc2bc960e888979059a0c90fbb24e47c7831b5b4e85d7f23914454d54eba0ec ./src/features/create/WorkEditorPage.tsx +a85e75ce4bcbce4f28aeafb5b9ef0ac76c685f789298ef4b482cab46ed8e353c ./src/features/discover/DiscoverPage.tsx +b7b8a4fc716a88878adf66fc22d4273751dd7a2e028c90cb30a9737606098014 ./src/features/friends/AddFriendFlow.tsx +dda59ac0d08cc04547da5626e8c6036e5606cd43450c2e91cd2ed971517e14f0 ./src/features/friends/FriendsPage.tsx +535f57b1de3d76d5a80630a67668fa101829c232c59d0a4d61a95c2e74cb856d ./src/features/friends/PlayTogether.tsx +372d97300e9ab2c0a632c6e2371cf7540afe8f2143dd7b852b122994adbb7306 ./src/features/games/GamePlayPage.tsx +4c5c507164a09e9add0b9b5649d8c7e19ff63ba219acc6e4aec53cfb1a04a86c ./src/features/games/GamePreparePage.tsx +88622c599382bc4c9ac4e0c7b46257b53b8835d411f515660d713971c5e03fc1 ./src/features/games/boards/CaboBoard.tsx +f59ed473977a50ed56bbd830f7b49843993787b80eee55bdb7ed4a35b090aa2b ./src/features/games/boards/RoyaleBoard.tsx +e80f7923043034fd78d3b5a6e17b27997797a5ee433449fb584cdb0570b6648c ./src/features/games/boards/SplendorBoard.tsx +c74fb96af14a7eba4c597e52b58612d4916557c4167e28fc3f3d511e5c40d10f ./src/features/games/boards/shared.tsx +6e1d2c80dcf2a2ac8a50cf7c1defa469c5232f86bb5b9c7831a9acc49c9a7adf ./src/features/games/boards/splendor-art.ts +0b2d22f140f0ffdac4d2b17d3882a575acc299378914b4f8b282197b6cd86b89 ./src/features/misc/NotFoundPage.tsx +df497adc422972222999cafeca2184bd16d5b6204e584582eff2390e038c0241 ./src/features/mock/MockPanel.tsx +2e1b6078e493e88a8a8940569f32aeaa5ded31217dc5a967951413f96f8f2462 ./src/features/recent/RecentPage.tsx +d912f040d2ba5c7a59d3ace0f967b4663bffd06284eb48c21edc5b3fcf446d31 ./src/features/story/StoryDetailPage.tsx +af1786723eefe778b49f85111786878078af13f824dc958b1f7e9b60e88ab1da ./src/features/story/StoryPlayPage.tsx +44a675ae8ee98f5eed2268549169058b5953940b00d7a06255cf9448bb0fb8b4 ./src/features/story/StoryPreparePage.tsx +cfd42056e8255307700c9b318e4c9d91a9c53af8a149efcb2d5ca27196354f29 ./src/games/engines/PROVENANCE.json +d24623a705e033a149fb53e9f985996dcad770c9d4ab6dad34068aeef2515d6c ./src/games/engines/cabo/core.d.ts +b18ef94aa13d85c6086ce2bce2e4b9a5e61e9fda7bc106c1ed4eef015e830b9a ./src/games/engines/cabo/core.js +7f70bd952b9cc5fc3ce6534a1177ec7c06cadd9fb52ae226e6393b5a2385aa2d ./src/games/engines/cabo/engine.d.ts +020a77057827a5c2a6345612fdbee106645d7515d34851dac36b7e882fde1913 ./src/games/engines/cabo/engine.js +8dee042a73e64bb64214ad458d7a1033609105886075ed389652070b52ede13c ./src/games/engines/cabo/rule-evidence.d.ts +e006d9546abe89b96543c0e5d0ce2fdf8194aca5abd42a9222dce4050f769813 ./src/games/engines/cabo/rule-evidence.js +6c42f3a38187b0848a297f0ee96465da9e325dc5fdc64bce983988c1a30ceed2 ./src/games/engines/royale/engine.ts +407fd2200b5fc89a87aacc5dbe3388431cabfae04f2d8b8e8e83af5006740b86 ./src/games/engines/splendor/splendor/npc.d.ts +981a5908b2abf871bb8be770b67abd415ca5ac22bf8710524765f25dd6d54fb2 ./src/games/engines/splendor/splendor/npc.js +9234e8fb2cd3b6aeca73ce32be0e5699f8f589229bd39187ed9c5b8d81a03258 ./src/games/engines/splendor/splendor/rules-v2.d.ts +4f4b32677f7ebcbf42082dba4e0395b3f4965e571bd16ff0a86f9281112c0699 ./src/games/engines/splendor/splendor/rules-v2.js +dd17e1594201aad4a6e84b54b7f5daa4879e0b99e96798d2866d2086679e4576 ./src/games/engines/splendor/splendor/rules.d.ts +62c785dfea2c79091cea32f5549d2b72f12ab2933502a2d3b1fa80f91ed030fa ./src/games/engines/splendor/splendor/rules.js +02efbf4d7483d941609d56d854ad7a8abe4b9f9c87993842eca1c45452615afb ./src/games/engines/splendor/src/data.d.ts +39a0043dc1d85447c7604efd0713738b178511b318f8aa554ff422cfb38ee056 ./src/games/engines/splendor/src/data.js +9c13ef0c3499677e1d7924962d9f3e53f96a0536856e330c3b2f91557de55207 ./src/games/engines/splendor/src/engine.d.ts +f710ec9198ccdadcd9b754170ecec9629dffc1b7e1f9659f1b5c481fa4a3d4ee ./src/games/engines/splendor/src/engine.js +c2ab485b65673e523fdfab4d943f56020c5d687d3129f51d7981340704c81004 ./src/lib/util.ts +536456f0682b21b8922eaf307c8aa5fb218b3eff5cbdd07a5f3903b7a27ab96c ./src/live2d/LiveStage.tsx +df931ba0a50621239e55b58066faac35238ace989bd2a9dcc6935998fe16b4d1 ./src/live2d/audio.ts +7acca57ea0818b836c061917c5c923a699f3297ef52d41380c56e5c68a076403 ./src/live2d/carrier-parameter-units.ts +e3d4de41b0219588ab4b1048de54945fb3f8a655a7a75a6c44a864db570a97e4 ./src/live2d/characters.ts +7cd7a84f564d39f724f5934288cb48a92b8b6366fd3d58a02067236c3f8e6d1c ./src/live2d/director.ts +8c73909b121f365ccf5b08ec609d1b48453c2c194cb81c9d6797b187c9844f14 ./src/live2d/framework.ts +f03bc51910b24b507e29d3bbc8183b6ed937ca2989d2d485b3fb8e21a2f73b47 ./src/live2d/motion.ts +987d341b0074075401505fd18276cdd8ff2c344620394c982663316c1d326001 ./src/live2d/performer.ts +0642dd4b2c3c2c341fce7111e97a4c7ed563b2e5f2c34c53ce920f569a3c0181 ./src/live2d/runtime.ts +97d0d8667c72f01656f7a943e1564d9add3b1e9dc5833c8673aab028aa086cac ./src/live2d/verified-frame-writer.ts +4d9b42037f148c443fca0ac407461a5feca4e2583c0b2c9c884612422914fc2c ./src/main.tsx +7096dfdd3aa9829bc7cd73e3936c87b1153100df8a1cb8ad2b4dfb678aa569a1 ./src/mock/catalog.ts +302e0903062c9c7d9dc957c512034360b96256e7c5c286e669e8f51278ebfcb8 ./src/mock/db.ts +239d915af92711c1ea0b4cb7c35467a17e1f8dcc87e87e37ee988e28210867dd ./src/mock/games/modules.ts +4f1bd18bbcfcefae93bd65008489e90d8affb34b80640d279b3dfb8728ff8301 ./src/mock/http.ts +0943a8409eaa1579fd36ac01fdd13a21c8862800b7d95c623c44d548aee9ca87 ./src/mock/replies.ts +668b610111b4be66bdc34a3048dc973d37880e8d94c32abf0cedc399dd9fdafd ./src/mock/scenarios.ts +9f1985079c24e09cd088acc7933a4381baa484020a947fedcdd562069f0d6a40 ./src/mock/server/account.ts +f40704d193a51c7053346d0fee814829f985222c8237c981ff8dcf5333156bbf ./src/mock/server/catalog.ts +f48396573dcd94d6672b10ab660c6ee332e459b21593a1f05335e85b0db6d692 ./src/mock/server/chat.ts +5ce1a6da856f7cf641e22c58f99c31855cc4124449af224f84fe4c59e0ccad55 ./src/mock/server/context.ts +978623c5eb614c9c0117425123d83b78e115b889c37cc13b126b0128740e04bc ./src/mock/server/friends.ts +927a46585b94d40208917c06b123f61da0dc3333f74926549f9a4608e2fbd16a ./src/mock/server/games.ts +c3308c13554c6c98608b41706191cbf981c27f8180b6a63f680a38a80125141e ./src/mock/server/index.ts +c44ca0747abfa7c6d4bb55f843be0e871155fa11bc1fe6fefaa3cee029d7d1cf ./src/mock/server/records.ts +f9025f97e2461fe3aa14f9a7a1c40be5affacc266472f149aec9e7b1c356ed6b ./src/mock/server/stories.ts +d40e1742e7bda1afd87b5f6084301bb3f9467ffbef7cbc1cab59496710966fec ./src/mock/server/works.ts +c1715ae950aacb9aab3fceab3a0d65c9f2c9a5044a4525dbedfa799f1d8238e6 ./src/styles/base.css +3468344d0e8e5e688c764442a0c058a3cd8fd404db47c78cd454b55a6fd12181 ./src/styles/chat.css +b6374de06ea4fff057e1fac8a0a43f146fa6c6c07f1e912004d9fa011f84ba89 ./src/styles/games.css +194ecabf6a070e14ca49da9fe3a1cc57716b3b506a980108d97d514b8a5efe4a ./src/styles/pages.css +e9dc11b4c7d7d1e729ab443ad72bab26e23a626d224f5f40b8855b1f55b6049f ./src/styles/shell.css +4699ed5b8ef1af6adb1f104619cdec7fc7d64ea311146a8bafcaa597eda225c4 ./src/styles/stage.css +0133fbc3260c5ac5b154f2379a9a520c964312226b40a5aeb25a2aac5f56a9dd ./src/styles/story.css +b5354a6ff7f851f72321a0ee52e4981400bec167abdcf7649bb031cd73577fba ./tests/cabo-rules.test.ts +1ee65949154e6acba11e6fceadfa61138e5e6946b537ef688a98898fe5dd9301 ./tests/components.test.tsx +2e41bbf815c25bc743f3e9daa0c8db4448b10cb35edfa84bb5e86b805c486d83 ./tests/engines.test.ts +e9c0c88fdd69d1131d45bd42ac2c51930f3914fbf376ef9a7cd2e16b5ba09695 ./tests/mock-api.test.ts +bedef72e2eed2600589f0c7b67cd6a2742c577cea0fd83f98a68bcfe13281b55 ./tests/richtext-router.test.ts +01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b ./tests/setup.ts +77401ba98f0128f2f0dcc48ebd84807b82f7b81c66c34d24567085f17cea2695 ./tsconfig.json +b2196f0e01f841c19e3fabf582b16134ae036d4878d4052ea4238ed1d7c9efd1 ./vite.config.ts diff --git a/everwith-claude-frontend/docs/ASSET_LOCK.json b/everwith-claude-frontend/docs/ASSET_LOCK.json new file mode 100644 index 00000000..5e69ff79 --- /dev/null +++ b/everwith-claude-frontend/docs/ASSET_LOCK.json @@ -0,0 +1,1498 @@ +{ + "schema": "everwith-claude-asset-lock-v1", + "source_package": "Everwith_Claude_Frontend_Handoff_2026-10-13", + "copied_at_utc": "2026-10-13T20:49:37.566Z", + "policy": "byte-identical copies verified against the package SHA256SUMS; no resize/re-encode; private user-authorized handoff; commercial/redistribution rights unverified", + "files": [ + { + "rel": "assets/companions/feed/arthur.webp", + "dest": "public/handoff/assets/companions/feed/arthur.webp", + "bytes": 362768, + "sha256": "4aedb582201d0903f64673b0c73a0b7ad195aa0e89a5bce0c25804b0aef8c657", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/iris.webp", + "dest": "public/handoff/assets/companions/feed/iris.webp", + "bytes": 257568, + "sha256": "355699748b9b06aab334e760e6a7d879e65504aa4850357cc012553997c84aa4", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/lena.webp", + "dest": "public/handoff/assets/companions/feed/lena.webp", + "bytes": 466126, + "sha256": "35f3da37d168b21f50ff77912d64769e33c18e609c7425e12e0214e5b452a55b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/marcus.webp", + "dest": "public/handoff/assets/companions/feed/marcus.webp", + "bytes": 247718, + "sha256": "ffe420bc9bbfcdc7d49593e647afaeb4df68f938a4066a72861b48835dbbcc9a", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/nora.webp", + "dest": "public/handoff/assets/companions/feed/nora.webp", + "bytes": 560612, + "sha256": "a67ddfa6a6d6a0913d975d71b63a4cd09dde9a722a57757d8e861f3d0d87bc6f", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/provenance.json", + "dest": "public/handoff/assets/companions/feed/provenance.json", + "bytes": 3623, + "sha256": "c4156596ed04556e969f3128ab409ccfd029d01e7e45a475ea1d135f869b4b94", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/scene-amber.webp", + "dest": "public/handoff/assets/companions/feed/scene-amber.webp", + "bytes": 211376, + "sha256": "d2e3dfaea7e6ccb841f32a5591d29af75afa1d84385b1a85b5fd2ba1d512eab9", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/feed/scene-rain.webp", + "dest": "public/handoff/assets/companions/feed/scene-rain.webp", + "bytes": 219068, + "sha256": "fc2ca71680561d4c444cf465accbbb9b4aab34ba63b9ce9f5eb5ff1c51f3bef3", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/cabo-poster.webp", + "dest": "public/handoff/assets/companions/game-previews/v1/cabo-poster.webp", + "bytes": 12016, + "sha256": "a87b201d83d2ae332872152bd88634f016d73ae7a8edcee0adac92137d70dac0", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/cabo.mp4", + "dest": "public/handoff/assets/companions/game-previews/v1/cabo.mp4", + "bytes": 115043, + "sha256": "5e29710d89445bf6c7daa91fa4e543ad4dd1764b036359245ef02e70789bcde4", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/manifest.json", + "dest": "public/handoff/assets/companions/game-previews/v1/manifest.json", + "bytes": 25355, + "sha256": "a188ca90cc034a80344b7a6c0a54a516da8fcbc5b485cc3e40d726e33f710f98", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/royale-poster.webp", + "dest": "public/handoff/assets/companions/game-previews/v1/royale-poster.webp", + "bytes": 29726, + "sha256": "a5b849fd14f7c24bd83cb1623acf0a2bcbb8dd01012dc774731f639aa7e37542", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/royale.mp4", + "dest": "public/handoff/assets/companions/game-previews/v1/royale.mp4", + "bytes": 237619, + "sha256": "ac07207ec3083277f7d18f4472ac02768b82a805b75a3b80148b3074ebe3867d", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/splendor-poster.webp", + "dest": "public/handoff/assets/companions/game-previews/v1/splendor-poster.webp", + "bytes": 32592, + "sha256": "b3cc1a9499fe6f38e9945dce794d09eb7df9a6d68e88431177cfcee7d707c163", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/game-previews/v1/splendor.mp4", + "dest": "public/handoff/assets/companions/game-previews/v1/splendor.mp4", + "bytes": 247629, + "sha256": "e0e102fb446f609fbff922c2e898009d49ee1503181aa8808aaf725a5cdb06c4", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/arthur-local-neutral.png", + "dest": "public/handoff/assets/companions/portraits/arthur-local-neutral.png", + "bytes": 105501, + "sha256": "40cfc3210bb385f87431134fc5b7c5adaa8343e97ecd7160ed0010fb49e5c52f", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/arthur-r7-neutral.png", + "dest": "public/handoff/assets/companions/portraits/arthur-r7-neutral.png", + "bytes": 231957, + "sha256": "ddba39a2fe3381dc80e0a24127b6c34c4393bd4f7fd414ecd4c9988d2394cc6b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/iris-local-neutral.png", + "dest": "public/handoff/assets/companions/portraits/iris-local-neutral.png", + "bytes": 124891, + "sha256": "00e6b1b33384c28b3c564e466b9e29075d848d1abebab7c70b4dc651c5e3f1de", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/lena-local-neutral.png", + "dest": "public/handoff/assets/companions/portraits/lena-local-neutral.png", + "bytes": 149325, + "sha256": "e8145352bb6ab17cc16e05de268845f46ad2e976ccd49cdf899b5315437d9f7d", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/lena-r5-neutral.png", + "dest": "public/handoff/assets/companions/portraits/lena-r5-neutral.png", + "bytes": 112381, + "sha256": "b5e4524a5b904a658b6fd5d7d59ddb66aa44042fd136216ac94ef5749210e143", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/lena-soft-anime-v2-neutral.png", + "dest": "public/handoff/assets/companions/portraits/lena-soft-anime-v2-neutral.png", + "bytes": 1782649, + "sha256": "f44a57389e516d2ec441920a65603e79bb538c5014a01ae528d961ca17c97060", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/marcus-local-neutral.png", + "dest": "public/handoff/assets/companions/portraits/marcus-local-neutral.png", + "bytes": 72316, + "sha256": "fecc101c9a8cf4ca906402e3ddbbd74bc752e2f0aae1917e759d96e3a4b5d457", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/marcus-r3-neutral.png", + "dest": "public/handoff/assets/companions/portraits/marcus-r3-neutral.png", + "bytes": 61217, + "sha256": "259ab41edbe5106d6b492b25b246c2b34ced072a2ebab7a128beda785874b857", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/nora-local-neutral.png", + "dest": "public/handoff/assets/companions/portraits/nora-local-neutral.png", + "bytes": 142453, + "sha256": "1b41b482c61bea26d5de011e2ba59bcdecf2c8e072d05d6afad13863d38b0998", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/portraits/nora-r4-neutral.png", + "dest": "public/handoff/assets/companions/portraits/nora-r4-neutral.png", + "bytes": 285853, + "sha256": "4193f55ebcc8bb64933bcd1d6ce7ded3ee05ffb410c06df5e2d570e8423cd59d", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/companions/scenes/lena-rain-room-illustrated-20261007.png", + "dest": "public/handoff/assets/companions/scenes/lena-rain-room-illustrated-20261007.png", + "bytes": 2077517, + "sha256": "ad03531b9a5436091148b8e1dad8e6316132c459fcdaa2257d1ec8ab737a0a49", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-illustration/provenance.json", + "dest": "public/handoff/assets/games/cabo/card-illustration/provenance.json", + "bytes": 1239, + "sha256": "b865a8ab25616b3a0bfe1c9002d5591a90448fa37e2c19f15f21e4a64f834a1b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/back-05209a8c19595393551ebb5ed1456aed1983639089b4683bd9e3ea7e0dbc0646.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/back-05209a8c19595393551ebb5ed1456aed1983639089b4683bd9e3ea7e0dbc0646.webp", + "bytes": 2329890, + "sha256": "05209a8c19595393551ebb5ed1456aed1983639089b4683bd9e3ea7e0dbc0646", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/illustration-print-1-zones.json", + "dest": "public/handoff/assets/games/cabo/card-rank-art/illustration-print-1-zones.json", + "bytes": 2686, + "sha256": "b77c75599c955ae9183bd173c0179aa06112287b5570c29253c376b184752c0e", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/manifest.json", + "dest": "public/handoff/assets/games/cabo/card-rank-art/manifest.json", + "bytes": 12213, + "sha256": "7259c0fdf5a8b150a8f8dfe235edf7f29192d5ffc5ebee046baf0ba09815ba90", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-0-bbbcffd0cfbc223a9843fb04e1ca4200069fe3e032ddcb8c07ed4cdfc456cb55.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-0-bbbcffd0cfbc223a9843fb04e1ca4200069fe3e032ddcb8c07ed4cdfc456cb55.webp", + "bytes": 2381072, + "sha256": "bbbcffd0cfbc223a9843fb04e1ca4200069fe3e032ddcb8c07ed4cdfc456cb55", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-1-ba5f66650793fc05c80ee3f94722dcef0dadd74c06d21491d30b66da5e870d44.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-1-ba5f66650793fc05c80ee3f94722dcef0dadd74c06d21491d30b66da5e870d44.webp", + "bytes": 2268646, + "sha256": "ba5f66650793fc05c80ee3f94722dcef0dadd74c06d21491d30b66da5e870d44", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-10-e4a211840ba6d4760ff1030c248e09c28cfb1d520abfca9ad504e31ac91b95fe.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-10-e4a211840ba6d4760ff1030c248e09c28cfb1d520abfca9ad504e31ac91b95fe.webp", + "bytes": 2324466, + "sha256": "e4a211840ba6d4760ff1030c248e09c28cfb1d520abfca9ad504e31ac91b95fe", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-11-b359172e1aa83f23fd6f78116990622d6c7cace419d52f33c53447631e03fcc7.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-11-b359172e1aa83f23fd6f78116990622d6c7cace419d52f33c53447631e03fcc7.webp", + "bytes": 1981810, + "sha256": "b359172e1aa83f23fd6f78116990622d6c7cace419d52f33c53447631e03fcc7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-12-9d85bc6d569e257d59e322ef8d977233f778564612416f61dfe0b178e86f35da.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-12-9d85bc6d569e257d59e322ef8d977233f778564612416f61dfe0b178e86f35da.webp", + "bytes": 2150980, + "sha256": "9d85bc6d569e257d59e322ef8d977233f778564612416f61dfe0b178e86f35da", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-13-03f0eb5d8d2a5b695fa00dde23984ea4fb09614b4c172a4fd27432e039ae8da7.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-13-03f0eb5d8d2a5b695fa00dde23984ea4fb09614b4c172a4fd27432e039ae8da7.webp", + "bytes": 2343712, + "sha256": "03f0eb5d8d2a5b695fa00dde23984ea4fb09614b4c172a4fd27432e039ae8da7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-2-4df6bf6039b3bc5191a84e7ec83488c139f37edf0d9d5dc44a0498c249eb25b4.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-2-4df6bf6039b3bc5191a84e7ec83488c139f37edf0d9d5dc44a0498c249eb25b4.webp", + "bytes": 2234194, + "sha256": "4df6bf6039b3bc5191a84e7ec83488c139f37edf0d9d5dc44a0498c249eb25b4", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-3-4a5605bd0afbffb98933a3f3b73b08fbfc27c66f666062c94ad58bf364fb3267.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-3-4a5605bd0afbffb98933a3f3b73b08fbfc27c66f666062c94ad58bf364fb3267.webp", + "bytes": 2210268, + "sha256": "4a5605bd0afbffb98933a3f3b73b08fbfc27c66f666062c94ad58bf364fb3267", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-4-c44d265df6d838b51841510eeb0f26d65cb6c389d04083aaec9c31b1a459baf7.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-4-c44d265df6d838b51841510eeb0f26d65cb6c389d04083aaec9c31b1a459baf7.webp", + "bytes": 2341890, + "sha256": "c44d265df6d838b51841510eeb0f26d65cb6c389d04083aaec9c31b1a459baf7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-5-c099b701937893138611d1e7a289aea489a1ddeff774891c029584f12256e613.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-5-c099b701937893138611d1e7a289aea489a1ddeff774891c029584f12256e613.webp", + "bytes": 2169752, + "sha256": "c099b701937893138611d1e7a289aea489a1ddeff774891c029584f12256e613", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-6-7cd22f8e067743aafa90288af9f8533c9d22a29af18ea1ca550795bcadd43d90.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-6-7cd22f8e067743aafa90288af9f8533c9d22a29af18ea1ca550795bcadd43d90.webp", + "bytes": 2286878, + "sha256": "7cd22f8e067743aafa90288af9f8533c9d22a29af18ea1ca550795bcadd43d90", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-7-a922be09b8b9aa30d3ced0b5fee7ab3572278e0cd42b559bdcd011fe3197b0ef.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-7-a922be09b8b9aa30d3ced0b5fee7ab3572278e0cd42b559bdcd011fe3197b0ef.webp", + "bytes": 2489192, + "sha256": "a922be09b8b9aa30d3ced0b5fee7ab3572278e0cd42b559bdcd011fe3197b0ef", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-8-966777513baa30063249aca1876fa556670b44c9ffa57e7842fc583541763de6.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-8-966777513baa30063249aca1876fa556670b44c9ffa57e7842fc583541763de6.webp", + "bytes": 2419188, + "sha256": "966777513baa30063249aca1876fa556670b44c9ffa57e7842fc583541763de6", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/card-rank-art/rank-9-fa0ce016cb166aa420963cdfa36c1cd63bffefce68944a2da7f015eeaf580478.webp", + "dest": "public/handoff/assets/games/cabo/card-rank-art/rank-9-fa0ce016cb166aa420963cdfa36c1cd63bffefce68944a2da7f015eeaf580478.webp", + "bytes": 2292262, + "sha256": "fa0ce016cb166aa420963cdfa36c1cd63bffefce68944a2da7f015eeaf580478", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/cabo/embedded-surface-1.webp", + "dest": "public/handoff/assets/games/cabo/embedded-surface-1.webp", + "bytes": 354758, + "sha256": "1653e93650392964438cf3825c7b8335e0d202329796f6f19316ac3840bb7929", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "byte-decoded existing image" + }, + { + "rel": "assets/games/royale/generated/PROVENANCE.json", + "dest": "public/handoff/assets/games/royale/generated/PROVENANCE.json", + "bytes": 4718, + "sha256": "0534ef23e4dc6402e240ed44c920ea184e9d4f499cf61bfac08c7f6cc0336b79", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/royale/generated/casino-scene-atlas-r7.png", + "dest": "public/handoff/assets/games/royale/generated/casino-scene-atlas-r7.png", + "bytes": 2844647, + "sha256": "63849aa383f2e4167942e8c09c7382a58d7400750129721f7b2d771b4e1ca1a1", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/royale/generated/fictional-game-token-ornament-20261004.png", + "dest": "public/handoff/assets/games/royale/generated/fictional-game-token-ornament-20261004.png", + "bytes": 3464649, + "sha256": "1ab6a8137d0e986867b0622b42cae8b96a2d51cd4db441eaebb3d53c0e897a89", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/backgrounds/salon.png", + "dest": "public/handoff/assets/games/splendor/backgrounds/salon.png", + "bytes": 2157439, + "sha256": "a3ae52573ef267a1263c5d27289dcf505b403927972ef0a4e2919920cac7ad68", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-diamond-finish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-diamond-finish-round4-20261007.png", + "bytes": 1148365, + "sha256": "2c7dc99549263be949351860c7abeeae6c813cbc3bd1ab13d1706e13f3b8feb8", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-emerald-polish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-emerald-polish-round4-20261007.png", + "bytes": 1299028, + "sha256": "68694dac012219ba6bd18be63ab9303d68600e042e885aa4d878af284be71e1d", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-gold-finish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-gold-finish-round4-20261007.png", + "bytes": 1187842, + "sha256": "5c8a81d8e26f870a7fe988aab8f5d87d0fdf6cc50ba56ffad1442fc6537bfb8a", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-onyx-polish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-onyx-polish-round4-20261007.png", + "bytes": 1345197, + "sha256": "3ed1530e8f98a7ee0d33049519a9e0b8a37570ce39165e2522b261a0805c69e2", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-ruby-polish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-ruby-polish-round4-20261007.png", + "bytes": 1323541, + "sha256": "a2d2f81773715747a181fd39b35c095aa34932d3ac70aeffee510ece4a97f9aa", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/gem-sapphire-finish-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/gem-sapphire-finish-round4-20261007.png", + "bytes": 1298268, + "sha256": "59f8c59ff463249aff5759d87d5621214f593e02032488bc16c41074043fca08", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/nobles-a.jpg", + "dest": "public/handoff/assets/games/splendor/nobles-a.jpg", + "bytes": 846387, + "sha256": "e336034ad2f8cf2750c587763fe0f1423d8c21dc878c43dc8dc4a40728ecbd48", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/nobles-b.jpg", + "dest": "public/handoff/assets/games/splendor/nobles-b.jpg", + "bytes": 825278, + "sha256": "c36406d3637f2bc33d6b9ce4a921e19a49782284746b743e9a5d58c2a88add5a", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier1-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier1-20261007.png", + "bytes": 3441601, + "sha256": "80bcf2fcb127185af81cf42fd35743b099e376255a08b631e6d090e6a78724f7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier2-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier2-20261007.png", + "bytes": 3680985, + "sha256": "d3593a29c0651e4d3a12e26c4eb65b45db2ecc225f8f54e26bd093ce2472bd6b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier2-caravan-round3-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier2-caravan-round3-20261007.png", + "bytes": 3344055, + "sha256": "19433de50f8f6a6a53d4ec936c63484ec73ef64f50a81d318c524510485febcb", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier2-ledger-round3-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier2-ledger-round3-20261007.png", + "bytes": 2887104, + "sha256": "187c22c471ed10ab546d107637ca32a56d84e41a4a8455501cc392305aeb71c5", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier2-warehouse-round3-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier2-warehouse-round3-20261007.png", + "bytes": 3283569, + "sha256": "7d7333e010f65b5be9a939b2455050f79f406fd4dd8d506955264347d3cc36a7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier3-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier3-20261007.png", + "bytes": 3619712, + "sha256": "99f9b96b5e7eea989b9270c140d9e02e030fd52f3ff2c2bfe2f5559453cf9d4f", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier3-cathedral-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier3-cathedral-round4-20261007.png", + "bytes": 2893222, + "sha256": "54542bc411d5cbceecb8ecec71f345f3522e6ae4fbaeaeb98c326aebe74c7cde", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/renaissance-tier3-harbor-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/renaissance-tier3-harbor-round4-20261007.png", + "bytes": 3221384, + "sha256": "566d821ea626af01e345ac5880dfdb73dde599fa9f4d4ab3fa9bbe0dc7fb5249", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/teal-velour-calm-round4-20261007.png", + "dest": "public/handoff/assets/games/splendor/teal-velour-calm-round4-20261007.png", + "bytes": 3036261, + "sha256": "bd66a0b2b7948e2ee43e8b183ed948d3ff241e17229c5775499174424df9cf70", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/tier-back-1-20261005.png", + "dest": "public/handoff/assets/games/splendor/tier-back-1-20261005.png", + "bytes": 3400223, + "sha256": "18938ad6d49dff10fef9e5ec503e0a5ec47f4847315c2429df81779b454c9392", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/tier-back-2-20261005.png", + "dest": "public/handoff/assets/games/splendor/tier-back-2-20261005.png", + "bytes": 3707696, + "sha256": "b8ffdd408b79fcda584f8bea2ab00ccc1d3b1fcf70cea19897a1ab787a188184", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/tier-back-3-20261005.png", + "dest": "public/handoff/assets/games/splendor/tier-back-3-20261005.png", + "bytes": 3464861, + "sha256": "200a0c4b0b6b72ed4633d5ba91273063b95cbd7bf65677178bea5d6e81de1902", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/games/splendor/tier-backs-provenance-20261005.json", + "dest": "public/handoff/assets/games/splendor/tier-backs-provenance-20261005.json", + "bytes": 1062, + "sha256": "6d7b564f48489353a2c8665ab5aeb7c0b7ec0ebd77f890af3ff3be32c91f0961", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/arthur/arthur-expressions.4096/texture_00.png", + "dest": "public/handoff/assets/live2d/arthur/arthur-expressions.4096/texture_00.png", + "bytes": 4410501, + "sha256": "64bd1c45f9f75e1228107b5c99ea5c8a8d14c44bd7a2878c15b78441f188a33a", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/arthur/arthur-expressions.cdi3.json", + "dest": "public/handoff/assets/live2d/arthur/arthur-expressions.cdi3.json", + "bytes": 4806, + "sha256": "935a40b34a6be1f1b04c4df8b2cc3b648fba5f70910abdab8a8213aa07a534df", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/arthur/arthur-expressions.moc3", + "dest": "public/handoff/assets/live2d/arthur/arthur-expressions.moc3", + "bytes": 373568, + "sha256": "047e051a47f4eac96f68446d96a8e5d993a828319992eec05fcebb1da803785f", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/arthur/arthur-expressions.model3.json", + "dest": "public/handoff/assets/live2d/arthur/arthur-expressions.model3.json", + "bytes": 479, + "sha256": "b836f8eec002fb35969718fb6194840a53039bc8784eb2adc2407c983206c07b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/iris/iris-expressions.4096/texture_00.png", + "dest": "public/handoff/assets/live2d/iris/iris-expressions.4096/texture_00.png", + "bytes": 3633227, + "sha256": "27ad28e7b28994fd4b9f47145d332ff10dc896fac0810f1d0007e46f0bdf31e2", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/iris/iris-expressions.moc3", + "dest": "public/handoff/assets/live2d/iris/iris-expressions.moc3", + "bytes": 744640, + "sha256": "a53e1e6898e5ddb216052d5da3a743636ce24532a42a94d24c72ba70a159d53e", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/iris/iris-expressions.model3.json", + "dest": "public/handoff/assets/live2d/iris/iris-expressions.model3.json", + "bytes": 151, + "sha256": "b29dc946a739ff99d8bd64e6f0bab2d636a6a5cecf2a508ff7056409d743c7a7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.4096/texture_00.png", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.4096/texture_00.png", + "bytes": 4121445, + "sha256": "cc41ed45da39ec00463401a0a8b72e0d5f4a7833f6a75a57731980a816deb18d", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.blink.motion3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.blink.motion3.json", + "bytes": 1155, + "sha256": "399f91b7bfac0666f589ab7c6daace5e33e5c6d6e7c6e7ca4024b6db48a65110", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.cdi3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.cdi3.json", + "bytes": 4775, + "sha256": "d49cffc7d49ef9c4ecd34a37b569d54c362ef70bf45f40f2e45857ab805612fd", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.idle.motion3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.idle.motion3.json", + "bytes": 15999, + "sha256": "14674d50d8765df706ac253975629f7cffe97a2442248a41af59f68d9cd3f2dc", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.moc3", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.moc3", + "bytes": 367488, + "sha256": "91ade7978c80712860740e997eba5393686bb1f4948b622791eb6980659548c1", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.model3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.model3.json", + "bytes": 821, + "sha256": "7196fc113aa14e4f469a5303ba6dbe5678e243d1dd7df3889d2379868e716dcc", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.nod.motion3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.nod.motion3.json", + "bytes": 1751, + "sha256": "bfd9a247d5ec8479145c2dddfd5fdbf03e1c46df8d7460f07badd146c1e19418", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.physics3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.physics3.json", + "bytes": 6067, + "sha256": "3ecf3dd44c55bbae30295d2ebb7a05213660f35c90b15ba1be57f4092d563f14", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/lena/lena-soft-anime-v2.shake.motion3.json", + "dest": "public/handoff/assets/live2d/lena/lena-soft-anime-v2.shake.motion3.json", + "bytes": 1573, + "sha256": "81f50690b925cab5487c64cd1fc9781485a141f758a1f7085d36f0596bbfe54b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.4096/texture_00.png", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.4096/texture_00.png", + "bytes": 6993215, + "sha256": "44c0b0979b5c11f441aa85a8f1f1a72e4f2035b7c7cd38ae7ee977a2edf12d7e", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.blink.motion3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.blink.motion3.json", + "bytes": 1155, + "sha256": "399f91b7bfac0666f589ab7c6daace5e33e5c6d6e7c6e7ca4024b6db48a65110", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.cdi3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.cdi3.json", + "bytes": 4690, + "sha256": "a093f6ad5c430acb51a78a0ed1770744e4094d99d25b4dbc67822f380d8ce281", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.demo.motion3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.demo.motion3.json", + "bytes": 534751, + "sha256": "7a569d0cca4e81050a647f8cf62de7d4402dfe8596104c4db4172d4d46828ced", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.idle.motion3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.idle.motion3.json", + "bytes": 15999, + "sha256": "14674d50d8765df706ac253975629f7cffe97a2442248a41af59f68d9cd3f2dc", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.moc3", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.moc3", + "bytes": 365120, + "sha256": "d50f76fa6a7902a06add8d5c76f9287c987dc82fd0efd467cd3989bf9ddc0c60", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.model3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.model3.json", + "bytes": 1020, + "sha256": "0b1eee2591d68b250a3478412115ab4d4ec6a444ae9ecbc9bb80e44a5064b556", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.nod.motion3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.nod.motion3.json", + "bytes": 1751, + "sha256": "bfd9a247d5ec8479145c2dddfd5fdbf03e1c46df8d7460f07badd146c1e19418", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.physics3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.physics3.json", + "bytes": 365, + "sha256": "68b5bbd65277b275fd682162db31f7ab442dba349997cab91b3100b214b3e2fd", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/marcus/marcus-live2d.shake.motion3.json", + "dest": "public/handoff/assets/live2d/marcus/marcus-live2d.shake.motion3.json", + "bytes": 1573, + "sha256": "81f50690b925cab5487c64cd1fc9781485a141f758a1f7085d36f0596bbfe54b", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.4096/texture_00.png", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.4096/texture_00.png", + "bytes": 9926737, + "sha256": "e1674a86cade714845c4531b3d4c7910e3c682191986443a1e2f7d238b3dfe86", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.blink.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.blink.motion3.json", + "bytes": 821, + "sha256": "31fad947cc4e9921127fe6ed91ac368d090e403dc3de882a30397695571c9e23", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.cdi3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.cdi3.json", + "bytes": 5008, + "sha256": "9c1d3ed06e4e54e622fce292bd60ad4910bdb908bfc174331cf7bc66f0938691", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.demo.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.demo.motion3.json", + "bytes": 513938, + "sha256": "ca809956ec28127cece96a60a191d23ae05cdaa63dac3429bb0be1313bb70973", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.idle.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.idle.motion3.json", + "bytes": 10958, + "sha256": "ac87e827a8b4c21762d65b932b17d009caf77e1982a967a6039623d71061fbcb", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.moc3", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.moc3", + "bytes": 635968, + "sha256": "d3eb973f5044ac9e7d14f2e348ef5c6c0d2b7b3b1f4c46814f4f9c1c427c2b5c", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.model3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.model3.json", + "bytes": 1279, + "sha256": "7f1fc4dbf08034bc7039e5584c4e4f3eb31e9c2559e9e46d474d24e4eb904331", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.nod.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.nod.motion3.json", + "bytes": 1230, + "sha256": "638ca6b03345727a79ff9d4a83ad736628dad8f44c16c66c0ae61fc0bb7c6218", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.physics3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.physics3.json", + "bytes": 6072, + "sha256": "3c9299431c1f86d2fe936b84a9920c6bb9af3a3a87d2edec3f561fbee327abe7", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.shake.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.shake.motion3.json", + "bytes": 1093, + "sha256": "51d13c274f3ad4ecd1c8b28dd12fb14b740d234eca321e3594648dd32a995317", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/live2d/nora/nora-expressions.showcase.motion3.json", + "dest": "public/handoff/assets/live2d/nora/nora-expressions.showcase.motion3.json", + "bytes": 514376, + "sha256": "b03a16ea38b47fac943f93bd56fcac383f6d5e1f8f564dbcf3a30ba9ef65b711", + "rights": "private-user-authorized; commercial-redistribution-unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Core/LICENSE.md", + "dest": "public/handoff/assets/runtime/cubism/Core/LICENSE.md", + "bytes": 520, + "sha256": "b81e37048010ef1d336106151201a0323c3309cae44aecdedf57831fe88fa991", + "rights": "original SDK notice; no new acceptance", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Core/live2dcubismcore.min.js", + "dest": "public/handoff/assets/runtime/cubism/Core/live2dcubismcore.min.js", + "bytes": 228042, + "sha256": "8741f739779b5d5210872bd3d7d99f0f1e56e6c87409e7d26d6bb4b80aa1ef47", + "rights": "existing Core notices; no new agreement accepted; redistribution unverified", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/LICENSE.md", + "dest": "src/vendor/cubism-framework/LICENSE.md", + "bytes": 2234, + "sha256": "7ed849bff1e6499aa7cd882e47d246ddbbe06817c1e84cb974df949488302614", + "rights": "original SDK notice; no new acceptance", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcalphablend.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcalphablend.frag", + "bytes": 2189, + "sha256": "855b247f02042a808a41e282ee4d7de7cc4533abeb5032b4ab8fe84f7dec107f", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrccolorblend.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrccolorblend.frag", + "bytes": 7525, + "sha256": "01d4bf6a14b4a738976e9a538767a21da0defa8816f3845a7df08a7713327538", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrccopy.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrccopy.frag", + "bytes": 429, + "sha256": "15c67a4ec3adda4c849aff2a8319a3f6aceef8a84d5e5e1f3fb7fd9cd681193c", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcmaskinvertedpremultipliedalpha.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcmaskinvertedpremultipliedalpha.frag", + "bytes": 1051, + "sha256": "1005cff8113de587cf1c0e295288d04d85f0f4ebc223684c8340e017fd8fc788", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcmaskpremultipliedalpha.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcmaskpremultipliedalpha.frag", + "bytes": 1043, + "sha256": "f84ff615393d26ea6ab7c28747803db6472524c92567c0ad01e8225fbab36a3b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcpremultipliedalpha.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcpremultipliedalpha.frag", + "bytes": 747, + "sha256": "c93abe215357cc782a25fc4ef20f5fe1f34e2cc193ee23d763b56b81fae5fce1", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcpremultipliedalphablend.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcpremultipliedalphablend.frag", + "bytes": 2038, + "sha256": "2155076275199a2946959838391dfb856e9d1db91b6ca3abd19ed70d4ff27e15", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcsetupmask.frag", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/fragshadersrcsetupmask.frag", + "bytes": 736, + "sha256": "3cccc0b68a56cfbf4bd4841b0a759431dd159812c38dc280981ba633fa0afe7a", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrc.vert", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrc.vert", + "bytes": 465, + "sha256": "291ed3f35f71f1e71e6df33fb055f009a8a9b36ee664f74f24f6cefef0c4029b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcblend.vert", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcblend.vert", + "bytes": 738, + "sha256": "2589837bd7a6a550a1c3af41b4fa776011e6e71f2531320fa39afcb83b209bf4", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrccopy.vert", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrccopy.vert", + "bytes": 392, + "sha256": "b7bc4ba7517f405af4812ac47d99bc2b633c99f463c1224029556b00e8dff3cf", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcmasked.vert", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcmasked.vert", + "bytes": 559, + "sha256": "6704a9df2e6d3ee5176c72fca4ba59358ba4827a07054b1e0a72a3c574e281fd", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcsetupmask.vert", + "dest": "public/handoff/assets/runtime/cubism/Framework/Shaders/WebGL/vertshadersrcsetupmask.vert", + "bytes": 536, + "sha256": "649aad2f6c0af55a7a4adcf33dbf78937ef9d77ed6ba917fd716176adce25afe", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/cubismdefaultparameterid.ts", + "dest": "src/vendor/cubism-framework/src/cubismdefaultparameterid.ts", + "bytes": 5783, + "sha256": "f92e20db8145f06b73728fb6ba4215bbaed723e26959471f2e291962142c95b5", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/cubismframeworkconfig.ts", + "dest": "src/vendor/cubism-framework/src/cubismframeworkconfig.ts", + "bytes": 1111, + "sha256": "92399ad1a572ec6048ea351ce4363b357f6f86471f9edb4af84bb7e16790828b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/cubismmodelsettingjson.ts", + "dest": "src/vendor/cubism-framework/src/cubismmodelsettingjson.ts", + "bytes": 24382, + "sha256": "58ff868afbdab05f905782be3ae411c787ad0c9c3e79ad254fc596d08bbe0b6e", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/effect/cubismbreath.ts", + "dest": "src/vendor/cubism-framework/src/effect/cubismbreath.ts", + "bytes": 3765, + "sha256": "06da2c2c10e91782fb99833c94842e6267dc996ddb35a1bfb99765f053c7729a", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/effect/cubismeyeblink.ts", + "dest": "src/vendor/cubism-framework/src/effect/cubismeyeblink.ts", + "bytes": 7222, + "sha256": "a1c26cfcf2656424f370e17d4b4cb0f9642db2dd452af8f562c67ced26ba64de", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/effect/cubismlook.ts", + "dest": "src/vendor/cubism-framework/src/effect/cubismlook.ts", + "bytes": 3657, + "sha256": "3c7f7dbea7b34c524bcf36fb5a15f9264d4a8a7228f5b124fdaddb2c82193f21", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/effect/cubismpose.ts", + "dest": "src/vendor/cubism-framework/src/effect/cubismpose.ts", + "bytes": 11698, + "sha256": "8eba3f692c8cd267ca75664d035565c6f61ac38201c56e7e2aa04389b964f1c2", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/icubismallcator.ts", + "dest": "src/vendor/cubism-framework/src/icubismallcator.ts", + "bytes": 1932, + "sha256": "d0c3a178e6c6b86697117eac21cd7faa422bf180b4cc84adecfc3491c573055f", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/icubismmodelsetting.ts", + "dest": "src/vendor/cubism-framework/src/icubismmodelsetting.ts", + "bytes": 6652, + "sha256": "87543afb93a5938211c08c2e5ae0116d0fd2bd813f92f3b5f48cf38105b1cf39", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/id/cubismid.ts", + "dest": "src/vendor/cubism-framework/src/id/cubismid.ts", + "bytes": 2281, + "sha256": "ff10538238bcda0a97756bf3e6017409acfdb901657174e7f6a027b965d34ee0", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/id/cubismidmanager.ts", + "dest": "src/vendor/cubism-framework/src/id/cubismidmanager.ts", + "bytes": 2413, + "sha256": "1ae3c3b2de5462d1bf6a98b5878eb7228d228d67adc7426bf549702fe6a5551e", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/live2dcubismframework.ts", + "dest": "src/vendor/cubism-framework/src/live2dcubismframework.ts", + "bytes": 8900, + "sha256": "dc20093ded82ed6704d94a443fa28961ba23aefb64fb76b9be35c8de869bd934", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismmath.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismmath.ts", + "bytes": 9603, + "sha256": "2e6816bfcecc459bb1c8bd7fcaa03f971b2545c90a9c75a7236cca9a0c282961", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismmatrix44.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismmatrix44.ts", + "bytes": 8038, + "sha256": "aa81745acf70315c536d0bada6cc422822765b2f44f8088a2ccea6cb6f9edc0d", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismmodelmatrix.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismmodelmatrix.ts", + "bytes": 4715, + "sha256": "31e8787385c4c26d78d2f627f373d7a446db1a72d38f247a442a9bc9de42ee0f", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismtargetpoint.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismtargetpoint.ts", + "bytes": 5764, + "sha256": "b09812890e639d89f96e0b1774a4ccdc076d1174fcb3daa95b0acd8102285999", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismvector2.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismvector2.ts", + "bytes": 4153, + "sha256": "369b0c0b842432f7156827bf77047276fb23b10301d9f5d40762497b9f75372d", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/math/cubismviewmatrix.ts", + "dest": "src/vendor/cubism-framework/src/math/cubismviewmatrix.ts", + "bytes": 8345, + "sha256": "3276768bd6efbe287a3384eb1960f6c1901f10137f38396096a9e8e84b9a65f5", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismmoc.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismmoc.ts", + "bytes": 3675, + "sha256": "99a80e2102af7b3ba10b15d684f965930707018b6b239a0ce6d14e3a867f266b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismmodel.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismmodel.ts", + "bytes": 62659, + "sha256": "fa4c21a270b3d18ae9f550cebf181d54504cf4b4198f1eeba6d0f3c55d7055a5", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismmodelmultiplyandscreencolor.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismmodelmultiplyandscreencolor.ts", + "bytes": 31866, + "sha256": "478b4fb805e6d4b5c450a58f29eaebcf75d176f87421797e218b0e3542a3b4b3", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismmodeluserdata.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismmodeluserdata.ts", + "bytes": 4248, + "sha256": "d4aaf59e04f834de345e0f9f1cced1062c28c9b1203bf09da17a0a8c2dddb6c4", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismmodeluserdatajson.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismmodeluserdatajson.ts", + "bytes": 3035, + "sha256": "4c8f546a393ac47bf8245dd8f7ae65e50eb61518578c33c3ad28ef23b2ffa4c1", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/model/cubismusermodel.ts", + "dest": "src/vendor/cubism-framework/src/model/cubismusermodel.ts", + "bytes": 15104, + "sha256": "41707e75f0ab36808a3ba42d40a477d0b33e7c7da73cbce5a3de0771bd957a9c", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/acubismmotion.ts", + "dest": "src/vendor/cubism-framework/src/motion/acubismmotion.ts", + "bytes": 14092, + "sha256": "649524b171f1a3ab8588a16dcb707a4f0da848f7fb87ad2b34148e0eea45ee08", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismbreathupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismbreathupdater.ts", + "bytes": 1698, + "sha256": "57acaadd41e6dac1ca31f69521166edf8366508e3dcf21ce9279687df72080df", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismexpressionmotion.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismexpressionmotion.ts", + "bytes": 12157, + "sha256": "20a75e357391116974a589fe8a9e5da3234cd214a214df94407f29b0152e86c5", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismexpressionmotionmanager.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismexpressionmotionmanager.ts", + "bytes": 9079, + "sha256": "17a12b23cf811cf8c7794b67beef53d47b10070ebd1d05a87becafe4a03fc403", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismexpressionupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismexpressionupdater.ts", + "bytes": 1989, + "sha256": "75fcf716ce50acd81c4d5f3e1179c6557e32fa4b8960d373aa36fe2734ec0c1b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismeyeblinkupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismeyeblinkupdater.ts", + "bytes": 2187, + "sha256": "a7e90b32cc8d47600ee0b2da44106efe4ea2a915556ccb28d6aa7f3ff93a5dcc", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismlipsyncupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismlipsyncupdater.ts", + "bytes": 3003, + "sha256": "390bc6da44e9d2b38704b990cbbe31442b562eeb2b7d4bc1a17a1a94034958a3", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismlookupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismlookupdater.ts", + "bytes": 2155, + "sha256": "a8fc4734ae53fa0f1b65b38a5358e6e7a456429a2743fc20f5fad637659bae18", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotion.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotion.ts", + "bytes": 37913, + "sha256": "eb3e8862f60feedd12bc47300d8486b044c39a8af21e66f12613ce941c8fa5f6", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotioninternal.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotioninternal.ts", + "bytes": 4928, + "sha256": "ede0f46ebdd0f4e0c25f5ba1825f6bccf658a463e08c0bc72a8af26230865805", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotionjson.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotionjson.ts", + "bytes": 12496, + "sha256": "92eb9dd6fbe6086e02952fec021cf0daa68a3fc335ae88dd19ee281591b34c64", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotionmanager.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotionmanager.ts", + "bytes": 3821, + "sha256": "7e88b825f99e4cbc1fd13ae07a7f5063a20ef9d96bf86855e426fb221813e72d", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotionqueueentry.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotionqueueentry.ts", + "bytes": 7248, + "sha256": "4619da4891829c7f8f5741a4b7f711a6cc2beb9fa06075a2b8caefec20b2ea19", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismmotionqueuemanager.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismmotionqueuemanager.ts", + "bytes": 10707, + "sha256": "1aa0f6a39ffe9d83e0e06217a9cf6ebdf3a6e593242ff84d08684bbd2f4a01bb", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismphysicsupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismphysicsupdater.ts", + "bytes": 1719, + "sha256": "003fda6b8a6537b75fff8f0cd4894d9f7f9ee0a1f668197d91d497adab3dbdd9", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismposeupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismposeupdater.ts", + "bytes": 1644, + "sha256": "58658839ea9aab83da69b91d6e54b9825ddb80cca47ee5c1cef615715e58c28e", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/cubismupdatescheduler.ts", + "dest": "src/vendor/cubism-framework/src/motion/cubismupdatescheduler.ts", + "bytes": 5149, + "sha256": "ab59717783d79bf3675e55cf2037683cced648bca4a47dcb438c78184c9b1b5e", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/icubismupdater.ts", + "dest": "src/vendor/cubism-framework/src/motion/icubismupdater.ts", + "bytes": 3688, + "sha256": "ba937ffd59bc52f4de426aadac4269630e0267d36b5ba9acab788993f586feea", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/motion/iparameterprovider.ts", + "dest": "src/vendor/cubism-framework/src/motion/iparameterprovider.ts", + "bytes": 1156, + "sha256": "c0b8c01f855dcf4e25a08ad19afb5136d8f92f3d6536ba125194c649df140b0b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/physics/cubismphysics.ts", + "dest": "src/vendor/cubism-framework/src/physics/cubismphysics.ts", + "bytes": 44141, + "sha256": "41457e7486d380825f30936e34951c0de5362769e8229b99c28702a5a398d734", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/physics/cubismphysicsinternal.ts", + "dest": "src/vendor/cubism-framework/src/physics/cubismphysicsinternal.ts", + "bytes": 8981, + "sha256": "db6ee02fe53f01f9575149d33ae44fc69876b32225da921b44bfc8a6d294082b", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/physics/cubismphysicsjson.ts", + "dest": "src/vendor/cubism-framework/src/physics/cubismphysicsjson.ts", + "bytes": 17868, + "sha256": "cc52bf67990131e322626729a044640b016e57b42dde80c0dfd4054af815d9b2", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismclippingmanager.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismclippingmanager.ts", + "bytes": 38519, + "sha256": "b38ca529a5bedac4eaa0d0ff04ae4ccaaaf3173d04250dc01af4e88e72730ec9", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismoffscreenmanager.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismoffscreenmanager.ts", + "bytes": 18723, + "sha256": "06c8d8c7c7fcdafd126f5f01881a023f8a4158c6222cf54e4672bd35a0609ebc", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismoffscreenrendertarget_webgl.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismoffscreenrendertarget_webgl.ts", + "bytes": 7489, + "sha256": "04ae8ccfa59c8dc1f43528ec0ceccd06b4180e582c802d14e38a1eaf14c0694c", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismrenderer.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismrenderer.ts", + "bytes": 13697, + "sha256": "42f6fa6343effdb9085cd1cd49b62f06f6e172483583c3cf325e0859aee8f9c8", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismrenderer_webgl.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismrenderer_webgl.ts", + "bytes": 62458, + "sha256": "f054aa514bf1ee4d162698de62bc3565d8e18ef348dd9234d6dab1e78bc0e678", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismrendertarget_webgl.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismrendertarget_webgl.ts", + "bytes": 8511, + "sha256": "8948ac1fb66fd32a289d9e07929ef2d8e41c6deb103948e54faa6f45ebad8f21", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/rendering/cubismshader_webgl.ts", + "dest": "src/vendor/cubism-framework/src/rendering/cubismshader_webgl.ts", + "bytes": 70603, + "sha256": "daf9bfcbed82f4763e401fbc1ccf740e1da0a99943dbec6afb3cdfb6c3d94a32", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/type/csmrectf.ts", + "dest": "src/vendor/cubism-framework/src/type/csmrectf.ts", + "bytes": 2019, + "sha256": "c199fdc276d2d66ed3abd140b137a3e127fc52b5735869849d450eacd5f416d8", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/utils/cubismarrayutils.ts", + "dest": "src/vendor/cubism-framework/src/utils/cubismarrayutils.ts", + "bytes": 1073, + "sha256": "0f63d3a9e6f25a097905503ccedfe21130b6b857238569341628f158d44afa60", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/utils/cubismdebug.ts", + "dest": "src/vendor/cubism-framework/src/utils/cubismdebug.ts", + "bytes": 5369, + "sha256": "0a526141825d6a980d838e95fc6856a4362173674bb6469d8bd40d054e9e7520", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/utils/cubismjson.ts", + "dest": "src/vendor/cubism-framework/src/utils/cubismjson.ts", + "bytes": 29319, + "sha256": "39d9f1806b7a6909098e1b8e2b7a1af567bbd0d6800b25beeaba4cb857c7b9f0", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/utils/cubismjsonextension.ts", + "dest": "src/vendor/cubism-framework/src/utils/cubismjsonextension.ts", + "bytes": 3691, + "sha256": "31e7a859c82c05e869651538077bb784737c459f6408a7aa3d460ba2697f3ddd", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/Framework/src/utils/cubismstring.ts", + "dest": "src/vendor/cubism-framework/src/utils/cubismstring.ts", + "bytes": 3356, + "sha256": "746c4da878164ecb6444addf2934e9086897b08f0fb68fb1a5f85132f5f76a7c", + "rights": "existing Framework license; inspect original notice", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/LICENSE.md", + "dest": "public/handoff/assets/runtime/cubism/LICENSE.md", + "bytes": 3042, + "sha256": "787a7d1454240865420b96ea9a20ab5e1a5ec3c1abced2e308288c2de8aac991", + "rights": "original SDK notice; no new acceptance", + "status": "reference; not product acceptance" + }, + { + "rel": "assets/runtime/cubism/README.md", + "dest": "public/handoff/assets/runtime/cubism/README.md", + "bytes": 585, + "sha256": "bf7a11a4106f24d8a84e95f00f83f9c92d49ad8e4d06ba8a63139321c3e9fc15", + "rights": "see handoff 08_ASSETS_AND_RIGHTS.md", + "status": null + }, + { + "rel": "assets/runtime/cubism/SDK_README_ORIGINAL.txt", + "dest": "public/handoff/assets/runtime/cubism/SDK_README_ORIGINAL.txt", + "bytes": 5576, + "sha256": "86272765f67de6cc527ea2e6bd4a0006681a1326c5de927475a50c2741857772", + "rights": "original SDK notice; no new acceptance", + "status": "reference; not product acceptance" + }, + { + "rel": "verification/MODEL_PINS.json", + "dest": "public/handoff/verification/MODEL_PINS.json", + "bytes": 4456, + "sha256": "b44cff064eb2631d3d5217d1d57c1b4efcd38d0ed74b58d0ed0689cc1dc7b126", + "rights": "handoff verification metadata", + "status": null + } + ], + "missing": [], + "mismatched": [] +} \ No newline at end of file diff --git a/everwith-claude-frontend/docs/GENERATED_ASSETS.json b/everwith-claude-frontend/docs/GENERATED_ASSETS.json new file mode 100644 index 00000000..4feba134 --- /dev/null +++ b/everwith-claude-frontend/docs/GENERATED_ASSETS.json @@ -0,0 +1,1754 @@ +{ + "schema": "everwith-generated-assets-v1", + "batch": "v2-batch-01", + "rights": "用户经 GPT 生成并提供;商用/再分发权未核实", + "note": "User-provided GPT output. Not part of the original handoff package; commercial use and redistribution are unverified. Only resized here (see transform).", + "source": { + "dropbox_folder": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/", + "manifest_sha256": "03e10cd7e985b70a2edd4fc4b3ca25ac71ee6ca6b2d35a9d3be518d59f1d533d", + "sha256sums_sha256": "f14a47a4366fb3c58dd34a710e78ffd825ecd0830339466a3f960c4a9d1c9ed7", + "slots": 45, + "images": 59 + }, + "derived_with": { + "pillow": "12.3.0" + }, + "assets": [ + { + "slot": "story-last-train-cover-tall", + "chosen_variant": "a", + "used_in": "Discover story card (4:5), story preparation poster (3:4), Recent / continue / play-together thumbnails", + "original": { + "file": "story-last-train-cover-tall__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-cover-tall__a.png", + "sha256": "916afdf6d0d0167926b03f0338d18094cc7cc541695ef39133ede9aedd43dcdb", + "bytes": 2602231, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:40:23.740Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. In the lower center a worn vintage brown leather suitcase with brass corners stands upright beside an empty wooden bench; a rain-soaked paper luggage tag hangs from its handle, its ink run and unreadable. Two old pendant lamps pool warm amber light on the bench and the terrazzo floor; behind them a tall window shows a wet, empty platform under deep blue night, rain streaks on the glass and a distant red signal light. Quiet, gentle mystery: a little lonely but safe. Keep the suitcase and the bench inside the central square of the frame. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Portrait 2:3 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. In the lower center a worn vintage brown leather suitcase with brass corners stands upright beside an empty wooden bench; a rain-soaked paper luggage tag hangs from its handle, its ink run and unreadable. Two old pendant lamps pool warm amber light on the bench and the terrazzo floor; behind them a tall window shows a wet, empty platform under deep blue night, rain streaks on the glass and a distant red signal light. Quiet, gentle mystery: a little lonely but safe. Keep the suitcase and the bench inside the central square of the frame. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 手提箱和长椅放在画面中央的正方形里. Continuity: modest early twentieth-century rural station, teal painted wooden trim, tall rectangular windows, cream plaster, dark walnut benches, terrazzo floor; when the suitcase is required, use the same upright worn brown leather rectangular suitcase, brass corners and pale blank paper tag. No train-brand identifiers. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-cover-tall.webp", + "url": "/generated/batch-01/story-last-train-cover-tall.webp", + "sha256": "8db4e77c2c9dadd8407f5210b0ee35536a91c12f92d06b1839ea48d244a003b5", + "bytes": 150984, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q82 method 6" + }, + "unused_candidates": [ + "story-last-train-cover-tall__b.png" + ] + }, + { + "slot": "story-last-train-cover-wide", + "chosen_variant": "a", + "used_in": "Story detail hero (4:3)", + "original": { + "file": "story-last-train-cover-wide__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-cover-wide__a.png", + "sha256": "5f8b1a0ab5c94fc87d3dd4d3417c28c486b4dcf46125b1276db0fa37a8cf6c67", + "bytes": 2678933, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:41:45.153Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Landscape 3:2 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. Centered: a worn vintage brown leather suitcase with brass corners standing beside an empty wooden bench, a rain-soaked paper luggage tag with run, unreadable ink. Warm amber pendant lamps; tall windows on both sides show a wet empty platform under deep blue night, rain streaks, a distant red signal light. Quiet, gentle mystery, a little lonely but safe. Keep the suitcase and bench within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Landscape 3:2 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. Centered: a worn vintage brown leather suitcase with brass corners standing beside an empty wooden bench, a rain-soaked paper luggage tag with run, unreadable ink. Warm amber pendant lamps; tall windows on both sides show a wet empty platform under deep blue night, rain streaks, a distant red signal light. Quiet, gentle mystery, a little lonely but safe. Keep the suitcase and bench within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体在中间 2/3 宽度内. Continuity: modest early twentieth-century rural station, teal painted wooden trim, tall rectangular windows, cream plaster, dark walnut benches, terrazzo floor; when the suitcase is required, use the same upright worn brown leather rectangular suitcase, brass corners and pale blank paper tag. No train-brand identifiers. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-cover-wide.webp", + "url": "/generated/batch-01/story-last-train-cover-wide.webp", + "sha256": "4e8010033d7d34d5463745a859f7fb837720d50784c0323a2cb97d75246a691a", + "bytes": 188054, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q82 method 6" + }, + "unused_candidates": [ + "story-last-train-cover-wide__b.png" + ] + }, + { + "slot": "story-last-train-scene-waiting-room", + "chosen_variant": null, + "used_in": "Story play scene banner: opening and beat 1", + "original": { + "file": "story-last-train-scene-waiting-room.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-scene-waiting-room.png", + "sha256": "f5a2ad0a9b4ec8c212bc8e674683ee159f0e8d643e205cf1c78c47b5b5ea118a", + "bytes": 2214677, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:48:06.874Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own newly generated v2 source; original and all prompts in reworks", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of the waiting room of a small old railway station on a rainy night, just after the last train has left, at about 1 a.m.: rows of wooden benches, a shuttered ticket window, a round wall clock with plain hands and no numerals, a small lost-and-found counter with a brass bell and a closed logbook, and an old brown leather suitcase standing beside a bench in the middle ground. Only a few warm lamps are on; the rest falls into soft blue shadow; rain on tall windows, a wet platform glimpsed outside. Calm, slightly mysterious slice-of-life mood; an ordinary working station, not abandoned. Keep all key objects in the middle horizontal band of the frame. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Edit this image only: resize the central midground wooden bench and its one tagged leather suitcase to 65% of their current size, move their grouping up so the whole grouping sits between y=440 and y=660 in this 1536x1024 frame. Keep only one suitcase. Move the numeral-free round wall clock down to have its full outline from y=455 to y=525. Preserve the closed ticket window, brass bell and closed blank logbook at the right counter, their current locations, station architecture, rain, blue night and amber light. No text, writing, glyphs, logos or humans. Keep native 1536x1024, no enlargement." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-scene-waiting-room.webp", + "url": "/generated/batch-01/story-last-train-scene-waiting-room.webp", + "sha256": "47dfaf081d85d488ff7384a129030aa3d701bbcbec6c812dfa21d8ee92513dd3", + "bytes": 128322, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-last-train-scene-corner", + "chosen_variant": null, + "used_in": "Story play scene banner: beat 2", + "original": { + "file": "story-last-train-scene-corner.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-scene-corner.png", + "sha256": "2c2ecbb9b6417c018661eda445c84107bf88891aec547ba75e322040776ff350", + "bytes": 2686362, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:51:44.412Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of a quiet corner of the waiting room of a small old railway station on a rainy night, just after the last train has left: an old cast-iron radiator, a single bench with a folded woollen scarf and a paper cup of tea left on it, a dim wall lamp, a rain-streaked window with the blue platform lights beyond. Tender, hesitant mood, as if someone has just stood up. Keep the bench and the scarf in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Wide landscape 3:2 view of a quiet corner of the waiting room of a small old railway station on a rainy night, just after the last train has left: an old cast-iron radiator, a single bench with a folded woollen scarf and a paper cup of tea left on it, a dim wall lamp, a rain-streaked window with the blue platform lights beyond. Tender, hesitant mood, as if someone has just stood up. Keep the bench and the scarf in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中段横条. Compose key props on one horizontal plane around 52.5% image height, entirely within a central 5:1 crop spanning 37.5%-67.5% image height. Pull the camera back enough to preserve key props in that thin strip. Continuity: modest early twentieth-century rural station, teal painted wooden trim, tall rectangular windows, cream plaster, dark walnut benches, terrazzo floor; when the suitcase is required, use the same upright worn brown leather rectangular suitcase, brass corners and pale blank paper tag. No train-brand identifiers." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-scene-corner.webp", + "url": "/generated/batch-01/story-last-train-scene-corner.webp", + "sha256": "62ae7c62ab0d5774503a349f5273484c7c65a5d743a6948d20958796256e560f", + "bytes": 168656, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-last-train-scene-duty-room", + "chosen_variant": null, + "used_in": "Story play scene banner: beat 3", + "original": { + "file": "story-last-train-scene-duty-room.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-scene-duty-room.png", + "sha256": "73aedfb7f2bd948aa2d3da88dcda8b53e81a7c68b3e2ff6d4b3db36cb8953ae5", + "bytes": 2189512, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:46:53.672Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of the small duty room / lost-property office of the same old railway station just before dawn: a tidy wooden desk with an open logbook and a fountain pen (handwriting not legible), a metal flashlight, a wall board with several keys on hooks, and the old brown leather suitcase placed on a shelf inside a wire-mesh locker. A green-shaded desk lamp gives warm light while the window shows the sky turning pale blue-grey at dawn. Orderly, calm, reassuring. Key objects in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, pale dawn light meeting warm lamplight, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Wide landscape 3:2 view of the small duty room / lost-property office of the same old railway station just before dawn: a tidy wooden desk with an open logbook and a fountain pen (handwriting not legible), a metal flashlight, a wall board with several keys on hooks, and the old brown leather suitcase placed on a shelf inside a wire-mesh locker. A green-shaded desk lamp gives warm light while the window shows the sky turning pale blue-grey at dawn. Orderly, calm, reassuring. Key objects in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, pale dawn light meeting warm lamplight, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中段横条. Compose key props on one horizontal plane around 52.5% image height, entirely within a central 5:1 crop spanning 37.5%-67.5% image height. Pull the camera back enough to preserve key props in that thin strip. Continuity: modest early twentieth-century rural station, teal painted wooden trim, tall rectangular windows, cream plaster, dark walnut benches, terrazzo floor; when the suitcase is required, use the same upright worn brown leather rectangular suitcase, brass corners and pale blank paper tag. No train-brand identifiers. Final composition override: ALL keys and the whole small key board must hang LOW immediately behind the desk, entirely between 39% and 60% image height; suitcase inside locker between 40% and 63%; whole desktop with open blank logbook, single fountain pen and flashlight between 53% and 66%; low green lamp shade between 39% and 50%. Camera pulled far back. Absolutely blank book pages: no handwriting strokes, marks or ink. Exactly one suitcase with rounded brass corner guards matching the cover, standing vertically." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-scene-duty-room.webp", + "url": "/generated/batch-01/story-last-train-scene-duty-room.webp", + "sha256": "2319267b88c1bd89e11ecf1bf58039c3b3947ca21fb4664368c81eff3278843b", + "bytes": 105316, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-last-train-scene-platform-dawn", + "chosen_variant": null, + "used_in": "Story play scene banner: open mode after the last beat", + "original": { + "file": "story-last-train-scene-platform-dawn.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-last-train-scene-platform-dawn.png", + "sha256": "a5817285ec7619a0b048062b1e765f6044fc3669944cb6d08c01ba48fb82e38e", + "bytes": 2568097, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:12:48.020Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of an empty small-town railway platform at dawn after a rainy night: wet concrete reflecting a pale peach-and-blue sky, rails curving away into light mist, a single wooden bench, an illuminated departure board glowing softly with abstract light rows only (no characters), overhead wires catching the first light. Hopeful, quiet morning. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, pale dawn light meeting warm lamplight, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Edit only the departure display: move the small glowing display down and to the right, mount it on a short dark teal post near the wooden bench with its complete rectangle from y=445 to y=500 in the 1536x1024 image. Its screen contains ONLY three continuous horizontal bars of amber light, absolutely no segmented characters or glyphs. Remove the old hanging display and the small plaque on the station wall. Keep the single bench, sunrise, wet platform, rails, wire system, empty station and style unchanged. No text, numerals, people or silhouettes anywhere. Preserve native size, no enlargement." + }, + "derivative": { + "path": "public/generated/batch-01/story-last-train-scene-platform-dawn.webp", + "url": "/generated/batch-01/story-last-train-scene-platform-dawn.webp", + "sha256": "9024a2db5a76eb389ebcd696540561ccd8c9d07cb263b13ef1cec5dac1fc73d8", + "bytes": 166868, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-promise-rain-cover-tall", + "chosen_variant": "a", + "used_in": "Discover story card (4:5), story preparation poster (3:4), Recent / continue / play-together thumbnails", + "original": { + "file": "story-promise-rain-cover-tall__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-cover-tall__a.png", + "sha256": "7ebd34d35659f80c2a8510a9166d8103801540068f2705598ada331285a9d621", + "bytes": 3235088, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:53:32.431Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain. A marquee lined with warm light bulbs, a few of them flickering, carries no lettering at all. Under the awning: a red velvet rope, a wooden doorman's lectern with a clipboard guest list whose writing is not legible, and a dark umbrella leaning against it. Wet pavement and puddles reflect gold and crimson light; rain falls in visible streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and the lectern inside the central square. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Portrait 2:3 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain. A marquee lined with warm light bulbs, a few of them flickering, carries no lettering at all. Under the awning: a red velvet rope, a wooden doorman's lectern with a clipboard guest list whose writing is not legible, and a dark umbrella leaning against it. Wet pavement and puddles reflect gold and crimson light; rain falls in visible streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and the lectern inside the central square. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 门口和讲台在中央正方形内. Continuity: same ornate 1920s theatre with red-brown brick, dark brass framed double glass doors, cream carved stone surround, amber round bulbs on a shallow brass marquee and crimson rope; interior walnut, crimson velvet and brass. No masks, human portraits or mannequin silhouettes. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-cover-tall.webp", + "url": "/generated/batch-01/story-promise-rain-cover-tall.webp", + "sha256": "98ea72d82f8bd32e52fb3e5dfaeaebe5dc3e9d4dc70cca9757aebeeea56adbf7", + "bytes": 246096, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q82 method 6" + }, + "unused_candidates": [ + "story-promise-rain-cover-tall__b.png" + ] + }, + { + "slot": "story-promise-rain-cover-wide", + "chosen_variant": "a", + "used_in": "Story detail hero (4:3)", + "original": { + "file": "story-promise-rain-cover-wide__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-cover-wide__a.png", + "sha256": "c6520259facf815ff60126c376aa080c0431b7589cad14952fc8574cda25ba40", + "bytes": 3261613, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:55:26.399Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Landscape 3:2 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain: bulb-lined marquee with no lettering, glass doors glowing warm, a red velvet rope, a doorman's lectern with an illegible guest list on a clipboard, a dark umbrella, gold and crimson reflections in puddles, rain streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and lectern within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Landscape 3:2 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain: bulb-lined marquee with no lettering, glass doors glowing warm, a red velvet rope, a doorman's lectern with an illegible guest list on a clipboard, a dark umbrella, gold and crimson reflections in puddles, rain streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and lectern within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体在中间 2/3 宽度内. Continuity: same ornate 1920s theatre with red-brown brick, dark brass framed double glass doors, cream carved stone surround, amber round bulbs on a shallow brass marquee and crimson rope; interior walnut, crimson velvet and brass. No masks, human portraits or mannequin silhouettes. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-cover-wide.webp", + "url": "/generated/batch-01/story-promise-rain-cover-wide.webp", + "sha256": "db7f7bb8740cbf50d76948cedd742d22e48aa0ca495de3e8c492f96fea6b6aca", + "bytes": 305484, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q82 method 6" + }, + "unused_candidates": [ + "story-promise-rain-cover-wide__b.png" + ] + }, + { + "slot": "story-promise-rain-scene-entrance-rain", + "chosen_variant": null, + "used_in": "Story play scene banner: opening and beat 1", + "original": { + "file": "story-promise-rain-scene-entrance-rain.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-scene-entrance-rain.png", + "sha256": "459fae2184ac561e1df74d0417c764da3c658e12dd9d4077ae84a58f74776976", + "bytes": 3279274, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T03:57:29.434Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of the entrance of an old ornate 1920s theatre at night in heavy rain, seen from across a wet cobblestone street: a glowing bulb marquee with no lettering, glass doors with warm light behind them, a velvet rope and a doorman's lectern under the awning, an umbrella, reflections in puddles, rain streaks. Mysterious but inviting. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Wide landscape 3:2 view of the entrance of an old ornate 1920s theatre at night in heavy rain, seen from across a wet cobblestone street: a glowing bulb marquee with no lettering, glass doors with warm light behind them, a velvet rope and a doorman's lectern under the awning, an umbrella, reflections in puddles, rain streaks. Mysterious but inviting. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中段横条. Compose key props on one horizontal plane around 52.5% image height, entirely within a central 5:1 crop spanning 37.5%-67.5% image height. Pull the camera back enough to preserve key props in that thin strip. Continuity: same ornate 1920s theatre with red-brown brick, dark brass framed double glass doors, cream carved stone surround, amber round bulbs on a shallow brass marquee and crimson rope; interior walnut, crimson velvet and brass. No masks, human portraits or mannequin silhouettes." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-scene-entrance-rain.webp", + "url": "/generated/batch-01/story-promise-rain-scene-entrance-rain.webp", + "sha256": "e052e77a4f4cb71eab8c30674915ddab09b037f6f91314360c7d42ea77de962d", + "bytes": 281324, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-promise-rain-scene-backstage", + "chosen_variant": null, + "used_in": "Story play scene banner: beat 2", + "original": { + "file": "story-promise-rain-scene-backstage.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-scene-backstage.png", + "sha256": "e50d3e4e50d8650c8c5022a34845ca71e65a0cbea89ea3bfbeecd0bcdcf45d68", + "bytes": 2198019, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:27:29.722Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of a quiet backstage corridor of an old ornate 1920s theatre at night: exposed brick wall, a costume rack with velvet garments, coiled ropes and a sandbag, a stage door with a small round window, and a wooden desk under a green-shaded lamp holding an open sign-in book (handwriting not legible) and a pen. A small locked wooden key cabinet hangs on the wall. Hushed, intimate, faintly suspenseful; muted crimson and brass. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Add exactly one slim dark fountain pen with a small brass cap on the desk beside the open blank sign-in book, near x=1320,y=660. Change nothing else. Keep all pages and surfaces blank, the key cabinet closed with its padlock, costumes, ropes, sandbag, green lamp, empty corridor, and round-window stage door. No text, glyphs, humans, logos. Keep native1536x1024." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-scene-backstage.webp", + "url": "/generated/batch-01/story-promise-rain-scene-backstage.webp", + "sha256": "79fb86e539c5d205b81e8d8fd5fe43d74561143b920cc7f90682c4a4d2f7f338", + "bytes": 140816, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-promise-rain-scene-office", + "chosen_variant": null, + "used_in": "Story play scene banner: beat 3", + "original": { + "file": "story-promise-rain-scene-office.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-scene-office.png", + "sha256": "bf0fc897ce1f07f08394248d179ac50c91f7e25db6b85842ba76c4a0c943fc5e", + "bytes": 1927799, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:16:01.199Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of the theatre manager's small office at night, the door half open: a heavy wooden desk with a single brass key whose paper tag is smudged with dark ink, an inkwell, a neat stack of programs with no readable printing, a framed photograph too far away to make out, rain on the window behind, a warm desk lamp. Calm, a little melancholy, quietly revealing. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Change only small decorative markings: make all drawer label holders completely blank unmarked brass; remove all lettering and small glyphs from every book spine and the border below the large framed empty-theatre photograph. The photo itself must show only empty architecture, no human or figure. Preserve the desk key with abstract ink smudge, inkwell, unmarked programs, desk photograph of empty buildings, half-open door, lamp and rainy window. No letters, text, numerals, faces or humans anywhere. Keep framing and native size unchanged." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-scene-office.webp", + "url": "/generated/batch-01/story-promise-rain-scene-office.webp", + "sha256": "56392a4b6445921d2155a6738ca722fac1136cae6ece6df32a9921d6c5bc9949", + "bytes": 114022, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "story-promise-rain-scene-entrance-after-rain", + "chosen_variant": null, + "used_in": "Story play scene banner: beat 4", + "original": { + "file": "story-promise-rain-scene-entrance-after-rain.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/story-promise-rain-scene-entrance-after-rain.png", + "sha256": "ea887326d14de6d48b9037109c94a83ed16b2720a08dfed7ce420b2c81f18cc9", + "bytes": 3292347, + "dims": [ + 1536, + 1024 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:00:00.407Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Wide landscape 3:2 view of the same entrance of an old ornate 1920s theatre as the rain eases: last drops in puddles, the bulb marquee now fully and steadily lit (no lettering), the glass doors open and spilling warm golden light, a closed umbrella hanging on a rope post, the faint glow of the auditorium beyond. Relieved, warm ending. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Wide landscape 3:2 view of the same entrance of an old ornate 1920s theatre as the rain eases: last drops in puddles, the bulb marquee now fully and steadily lit (no lettering), the glass doors open and spilling warm golden light, a closed umbrella hanging on a rope post, the faint glow of the auditorium beyond. Relieved, warm ending. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 3:2 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中段横条. Compose key props on one horizontal plane around 52.5% image height, entirely within a central 5:1 crop spanning 37.5%-67.5% image height. Pull the camera back enough to preserve key props in that thin strip. Continuity: same ornate 1920s theatre with red-brown brick, dark brass framed double glass doors, cream carved stone surround, amber round bulbs on a shallow brass marquee and crimson rope; interior walnut, crimson velvet and brass. No masks, human portraits or mannequin silhouettes." + }, + "derivative": { + "path": "public/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp", + "url": "/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp", + "sha256": "fef4ed9f75ec56400ad6c8420525c1f0d3b6ac7c905a745f46533ef46c3a7216", + "bytes": 300876, + "dims": [ + 1280, + 853 + ], + "mode": "RGB", + "transform": "Lanczos resize 1536x1024 -> 1280x853; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "backdrop-lena", + "chosen_variant": "a", + "used_in": "LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)", + "original": { + "file": "backdrop-lena__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-lena__a.png", + "sha256": "b5da63d9316c2f6653ce3949185a6c4e5e155f1be5d1f39d376dd2708dd1d417", + "bytes": 2208235, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:01:12.318Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of an investigator's study in an old theatre district at night: a tall arched window on the left with rain and soft city lights, bookshelves and neatly tied case folders on the right, a brass desk lamp glowing, a pinned street map with red string on a cork board (no writing). This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Portrait 2:3 background illustration of an investigator's study in an old theatre district at night: a tall arched window on the left with rain and soft city lights, bookshelves and neatly tied case folders on the right, a brass desk lamp glowing, a pinned street map with red string on a cork board (no writing). This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中间一半宽度保持干净、虚化(人物会叠在中间). Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-lena.webp", + "url": "/generated/batch-01/backdrop-lena.webp", + "sha256": "02a95515edcf6c409bc1488784621912d811ea319441bd57aafaaa3b54d25598", + "bytes": 94902, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [ + "backdrop-lena__b.png" + ] + }, + { + "slot": "backdrop-arthur", + "chosen_variant": "a", + "used_in": "LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)", + "original": { + "file": "backdrop-arthur__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-arthur__a.png", + "sha256": "877725684588df72948cb652071364dc01c005094a53fd14983bf89dd80ae322", + "bytes": 1929482, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:02:56.480Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of a theatre manager's office above a grand old stage: dark wood panelling, deep red velvet curtains, a brass key board, leather ledgers, framed playbills seen from too far to read, and an interior window looking down on a softly lit, empty stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Portrait 2:3 background illustration of a theatre manager's office above a grand old stage: dark wood panelling, deep red velvet curtains, a brass key board, leather ledgers, framed playbills seen from too far to read, and an interior window looking down on a softly lit, empty stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中间一半宽度保持干净、虚化(人物会叠在中间). Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-arthur.webp", + "url": "/generated/batch-01/backdrop-arthur.webp", + "sha256": "48d7f68eba34ff7669c2d37ed3db7652e107e302f16c56eeb49d6af9635a8e44", + "bytes": 73886, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [ + "backdrop-arthur__b.png" + ] + }, + { + "slot": "backdrop-marcus", + "chosen_variant": "a", + "used_in": "LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)", + "original": { + "file": "backdrop-marcus__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-marcus__a.png", + "sha256": "db527378ef4181f642cef73af2591e174604b4aad6ddc89829342f73dac6d856", + "bytes": 1946458, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:05:14.718Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of a stage manager's prompt corner backstage: a cue-light panel with small coloured bulbs, a headset hanging on a hook, ropes and pulleys of the fly system rising into darkness, painted scenery flats leaning against the wall, a clamp work lamp with warm light against the dark stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Portrait 2:3 background illustration of a stage manager's prompt corner backstage: a cue-light panel with small coloured bulbs, a headset hanging on a hook, ropes and pulleys of the fly system rising into darkness, painted scenery flats leaning against the wall, a clamp work lamp with warm light against the dark stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中间一半宽度保持干净、虚化(人物会叠在中间). Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-marcus.webp", + "url": "/generated/batch-01/backdrop-marcus.webp", + "sha256": "0aaef6240f727b0d3856defe5b55077e67ec3fdc736f9a6ae86846130b5466a8", + "bytes": 70352, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [ + "backdrop-marcus__b.png" + ] + }, + { + "slot": "backdrop-nora", + "chosen_variant": "a", + "used_in": "LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)", + "original": { + "file": "backdrop-nora__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-nora__a.png", + "sha256": "9c519d994bc89cfc5260fda6fd42098f8c7491a8c199ab4469391f894f8a999c", + "bytes": 2148042, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:19:59.717Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of a curious explorer's attic workshop at golden hour: a round window with low warm sun, old maps and a globe, potted plants, small brass gadgets and a telescope, open notebooks with sketches only (no writing); warm and inviting. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm golden-hour sunlight with soft teal shadows, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Remove ALL writing, letters, numerals, glyph-like ticks, labels and annotations from every map, globe and notebook. Keep maps and globe as ONLY simple unlabelled landmass outlines and plain grid lines. Notebook drawings remain simple leaves, mountains and geometric instruments without annotations. Preserve curious explorer attic, round sunset window, plants, telescope, brass gadgets, warm style, blurred low-contrast central half and native dimensions. No text, numerals, faces or humans." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-nora.webp", + "url": "/generated/batch-01/backdrop-nora.webp", + "sha256": "62e614f596f61a7562facecdf8676372fe905f3e9917e80947b9dd4153aa24f0", + "bytes": 102454, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [ + "backdrop-nora__b.png" + ] + }, + { + "slot": "backdrop-iris", + "chosen_variant": "a", + "used_in": "LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)", + "original": { + "file": "backdrop-iris__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-iris__a.png", + "sha256": "be814c797aed6464a4b5454d8ee292d12fc845f7b1bd1409b1939823c53bde50", + "bytes": 2224225, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:23:13.002Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of a quiet reading nook by a rainy window at dusk: a window seat with a soft knitted blanket, a tea set, a small stack of letters and a fountain pen (no readable writing), candle light meeting blue-grey dusk; calm and gentle. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Make all book spines completely unmarked, retaining leather and plain brass bands but no lettering or glyphs. Make all letter sheets and envelopes entirely blank unmarked cream paper, no writing or stamps. Keep fountain pen, tea set, rainy dusk window, candle light, knitted blanket, framing, gentle blur and low-contrast central half. No humans, silhouettes, faces, text, letters or numbers. Keep native size unchanged." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-iris.webp", + "url": "/generated/batch-01/backdrop-iris.webp", + "sha256": "ab9a7971e7d6ab87e48c5bf4f787eac39280e4f0b863630b902a39108cbd7ca4", + "bytes": 102388, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [ + "backdrop-iris__b.png" + ] + }, + { + "slot": "backdrop-generic", + "chosen_variant": null, + "used_in": "Stage backdrop for characters without their own (user-created work characters)", + "original": { + "file": "backdrop-generic.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/backdrop-generic.png", + "sha256": "43f0e979fe751eeca57bb79700e480e828557441750b1d994dee3ad7baada6a9", + "bytes": 1916431, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:25:35.084Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of own new v2 source", + "qa_status": "checked: full image and contact sheet/crop previews; accepted", + "reference_images": [], + "prompt": "Portrait 2:3 background illustration of a neutral cozy room at night with a window, warm lamps and soft bokeh lights, no specific profession; suits any character. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror.", + "actual_prompt": "Change only the small book spines in the left shelves: remove ALL text, glyphs and numbers so spines are plain unmarked leather with simple brass bands. Keep neutral cozy room, warm lamps, window and soft bokeh, plants, empty furnishings, low-contrast central half, style and native dimensions. No humans, faces, silhouettes, letters or numerals anywhere." + }, + "derivative": { + "path": "public/generated/batch-01/backdrop-generic.webp", + "url": "/generated/batch-01/backdrop-generic.webp", + "sha256": "d71aaceab1193400b3c8e19d0faf31d763067011370a776e582429f61b6b6599", + "bytes": 76548, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q78 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "game-royale-cover", + "chosen_variant": "a", + "used_in": "Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails", + "original": { + "file": "game-royale-cover__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/game-royale-cover__a.png", + "sha256": "955c06a04e47df7b5d6d37253fac7cfc90b3bb0b3d7a3718747ea1bb21aabf88", + "bytes": 2489067, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:39:21.030Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "new text-prompt generation in v2 thread", + "qa_status": "checked: full composition and central square, 4:5, 3:4 crops; accepted", + "reference_images": [], + "prompt": "Original key art for a casino dice board game. A stylized night boulevard of six glamorous fictional casino buildings in black-and-gold art-deco style with neon accents, seen from slightly above; in the foreground on green felt, a handful of colourful six-sided dice with white pips tumbling mid-roll beside a fan of fictional pastel banknotes decorated only with abstract guilloche patterns; gold light bokeh. Rich black, gold, emerald and ruby; polished premium tabletop-game illustration. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. No real casino names, city signs or real currency designs.", + "actual_prompt": "Edit this original game cover, preserving the same six fictional casino buildings, front-facing composition, dice colors, abstract pastel banknotes, premium painted black/gold/emerald/ruby style. Zoom out substantially so the ENTIRE cluster of six buildings (every rooftop), all SIX dice (including entire lowest red die), and ENTIRE banknote fan fit together inside a compact central rectangle, x=140..880 and y=380..1130, within the existing 1024x1536 portrait canvas. Make the complete subject group about 25 percent smaller. Add quiet dark night sky above y=350, smooth empty green felt below y=1180, and side margins. The canvas stays 2:3, 1024x1536, no enlargement. A centered square crop from y=256 to1280 MUST preserve every rooftop, die, and full banknote. No text, letters, numbers, signs, logos, people or watermarks. Banknotes have only abstract rosette/guilloche decoration, no glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/game-royale-cover.webp", + "url": "/generated/batch-01/game-royale-cover.webp", + "sha256": "eb96c3a6bcc8723df64499ff8d5fb6fcd1a3e13f401bc6f000e4a2eea46ff7ff", + "bytes": 156848, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q82 method 6" + }, + "unused_candidates": [ + "game-royale-cover__b.png" + ] + }, + { + "slot": "game-splendor-cover", + "chosen_variant": "a", + "used_in": "Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails", + "original": { + "file": "game-splendor-cover__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/game-splendor-cover__a.png", + "sha256": "98acbe94447fd96c01b014e65c4619857cfe40d18a51dafc109291df8c7ec4cb", + "bytes": 2757283, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:36:06.437Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and central square, 4:5, 3:4 crops; accepted", + "reference_images": [], + "prompt": "Original key art evoking the Renaissance gem trade. A merchant's walnut table by an arched stone window overlooking a Renaissance harbour city at golden hour; on a deep velvet cloth lie piles of cut and uncut gemstones — emerald green, sapphire blue, ruby red, diamond white and onyx black — with a few plain gold tokens, a brass balance scale and a closed leather ledger tied with a ribbon. Oil-painting look, warm light, rich textures. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art.", + "actual_prompt": "Original key art evoking the Renaissance gem trade. A merchant's walnut table by an arched stone window overlooking a Renaissance harbour city at golden hour; on a deep velvet cloth lie piles of cut and uncut gemstones — emerald green, sapphire blue, ruby red, diamond white and onyx black — with a few plain gold tokens, a brass balance scale and a closed leather ledger tied with a ribbon. Oil-painting look, warm light, rich textures. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体在中央正方形内. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space. Keep every required object entirely inside x=80..940 and y=320..1210 of portrait 1024x1536, with quiet sky or backdrop above and below. All rooftops and dice/cards/gem props fit in the central square. Artwork touches neither side edge." + }, + "derivative": { + "path": "public/generated/batch-01/game-splendor-cover.webp", + "url": "/generated/batch-01/game-splendor-cover.webp", + "sha256": "e164cd165c53bab2c6a4266b7e2235892d30ffc354e547919414445bb46d472c", + "bytes": 167242, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q82 method 6" + }, + "unused_candidates": [ + "game-splendor-cover__b.png" + ] + }, + { + "slot": "game-cabo-cover", + "chosen_variant": "a", + "used_in": "Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails", + "original": { + "file": "game-cabo-cover__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/game-cabo-cover__a.png", + "sha256": "a1d9d60fd0a3baccff34079e0e62bd139d5f66f3a2ce0f928d7fe66ecfa09002", + "bytes": 2618456, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:37:17.826Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and central square, 4:5, 3:4 crops; accepted", + "reference_images": [], + "prompt": "Original whimsical storybook illustration for a light card game. Four playing cards fan out in the center against a deep forest-green background with a subtle leaf pattern; each card shows a cute creature in lush leaves, flowers and mushrooms — a fox, a small octopus, an owl and a rabbit — and a fifth card lies face down showing an ornamental pattern of overlapping green, teal and yellow leaf scales. Bright friendly colours, gouache-like texture, clean shapes, soft shading, children's-book quality. The cards carry no numbers, letters or symbols. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. No scary creatures.", + "actual_prompt": "Original whimsical storybook illustration for a light card game. Four playing cards fan out in the center against a deep forest-green background with a subtle leaf pattern; each card shows a cute creature in lush leaves, flowers and mushrooms — a fox, a small octopus, an owl and a rabbit — and a fifth card lies face down showing an ornamental pattern of overlapping green, teal and yellow leaf scales. Bright friendly colours, gouache-like texture, clean shapes, soft shading, children's-book quality. The cards carry no numbers, letters or symbols. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. No scary creatures.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体在中央正方形内. Candidate A: balanced front-facing viewpoint and compact centered subject grouping, with calm negative space. Keep every required object entirely inside x=80..940 and y=320..1210 of portrait 1024x1536, with quiet sky or backdrop above and below. All rooftops and dice/cards/gem props fit in the central square. Artwork touches neither side edge." + }, + "derivative": { + "path": "public/generated/batch-01/game-cabo-cover.webp", + "url": "/generated/batch-01/game-cabo-cover.webp", + "sha256": "c9a4921c7a373b4a5f5f7a8167c8d3ff940228763012127a51cb2f2c234e79d2", + "bytes": 141044, + "dims": [ + 768, + 1152 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 768x1152; WebP q82 method 6" + }, + "unused_candidates": [ + "game-cabo-cover__b.png" + ] + }, + { + "slot": "empty-friends", + "chosen_variant": null, + "used_in": "Friends: no friends yet", + "original": { + "file": "empty-friends.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/empty-friends.png", + "sha256": "a22a1b2773e59440ccb176011c833e9f8f340b84ca35e335838b8f2788439186", + "bytes": 1407318, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:44:37.711Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of generated asset; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "two cups of tea side by side on a windowsill, gentle rain outside the window, a small potted plant; cozy and inviting. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "Edit this spot illustration only: recolor EVERY plant leaf and all greens to muted blue-teal #4B94B5, no green, olive or yellow-green anywhere. Remove the scarf/fabric completely. Retain exactly two tea cups, compact windowsill, simple partial rain window, one small potted plant. Use limited warm amber #E3B997, terracotta #CB5367, muted teal #4B94B5 and cream only, fine confident outline and light washes. Simplify details to read at 160px. Centered, at least 15 percent transparent margins. Preserve actual RGBA transparent background and native square dimensions. No white matte, checkerboard, text, letters, numbers or people." + }, + "derivative": { + "path": "public/generated/batch-01/empty-friends.webp", + "url": "/generated/batch-01/empty-friends.webp", + "sha256": "22d64e1ea99ad56f754be9ff909fdf174741b55a25ba92d9ba44fa4ed7a7a243", + "bytes": 44346, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "empty-recent", + "chosen_variant": null, + "used_in": "Recent: nothing yet / nothing archived", + "original": { + "file": "empty-recent.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/empty-recent.png", + "sha256": "3fc5aabb0c645905230745e5ced2c7fb225ea8bd699eb7246624767dcc8cbb11", + "bytes": 1021469, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:45:27.670Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in edit of generated asset; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "a small neat stack of blank ticket stubs, a ribbon bookmark and a single die, loosely arranged. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "Edit ONLY the die: remove every dot, pip, numeral and marking from ALL visible faces so this is a completely blank plain cream cube with subtle light shading. No holes or embossed marks. Keep the ticket stack entirely blank, preserve ribbon and existing amber/terracotta/teal/cream colors, thin outline and light wash. Preserve native square dimensions and genuine transparent RGBA background. No text, letters, numbers, dot marks, logos, people, white matte or painted checkerboard. Nothing else changes." + }, + "derivative": { + "path": "public/generated/batch-01/empty-recent.webp", + "url": "/generated/batch-01/empty-recent.webp", + "sha256": "6955a081460764fc59e08daa146267628f9acd9743b153119abcb80c799992d9", + "bytes": 31598, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "empty-create", + "chosen_variant": null, + "used_in": "Create: no works yet", + "original": { + "file": "empty-create.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/empty-create.png", + "sha256": "28cd27c2e0b0ea3c7526af83ddeb56b3ad90f2c84506d479c0c4d1692e1efbb8", + "bytes": 1167257, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:46:26.205Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "an open sketchbook with a pencil resting on it and a small desk lamp glowing beside it; hopeful. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "an open sketchbook with a pencil resting on it and a small desk lamp glowing beside it; hopeful. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体居中,四周留白. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/empty-create.webp", + "url": "/generated/batch-01/empty-create.webp", + "sha256": "026b9b8aba3c2f1208818f2c5e2c0463284e412bb95a26d9fdac1d4c90c5c1a4", + "bytes": 44396, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "state-not-found", + "chosen_variant": null, + "used_in": "Not found (route, character, story, run, game, friend, work)", + "original": { + "file": "state-not-found.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/state-not-found.png", + "sha256": "270604800fb08eb47e15ca043e4b95837acebd313154f9e27c144e8c8b2bdb78", + "bytes": 1730103, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:47:47.880Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "a small wooden door slightly ajar with a soft lamp glow behind it and a few fallen leaves on the step; calm, not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "a small wooden door slightly ajar with a soft lamp glow behind it and a few fallen leaves on the step; calm, not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体居中,四周留白. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/state-not-found.webp", + "url": "/generated/batch-01/state-not-found.webp", + "sha256": "c50e6a13caa826e9f359d5fcbd2437e0f7c988593ab47da5b498c5cb55de313a", + "bytes": 61204, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "state-load-error", + "chosen_variant": null, + "used_in": "Load failure (distinct from “nothing here”)", + "original": { + "file": "state-load-error.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/state-load-error.png", + "sha256": "e960e7744fe0ee00a1aed86ee1d45ceb35d1d1f05f284ea5be9e2a433a55c7fa", + "bytes": 846795, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:48:34.139Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "a short string of warm fairy lights, slightly tangled, with one bulb dimmed; gentle \"something went wrong\", not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "a short string of warm fairy lights, slightly tangled, with one bulb dimmed; gentle \"something went wrong\", not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体居中,四周留白. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/state-load-error.webp", + "url": "/generated/batch-01/state-load-error.webp", + "sha256": "978c18c4d84aa945f6a036029581b01af932e4b63fc509e09cfd32e166cc7420", + "bytes": 45350, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "state-signin", + "chosen_variant": null, + "used_in": "Sign-in needed (friends, chat) and the sign-in dialog", + "original": { + "file": "state-signin.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/state-signin.png", + "sha256": "449188d214ae4653f3fdfde3f61e2f941af7af3bd572bff6f707772fab26b36d", + "bytes": 1407242, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:49:42.157Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: composition, genuine alpha, 160px on dark/light; accepted", + "reference_images": [], + "prompt": "a small brass key resting on a folded letter sealed with a blank wax seal (no letters on the seal). Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "a small brass key resting on a folded letter sealed with a blank wax seal (no letters on the seal). Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 主体居中,四周留白. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/state-signin.webp", + "url": "/generated/batch-01/state-signin.webp", + "sha256": "091668c040c486562cd3e27f3e8402af79b7bd26f5d65a66d62a11964d6bcac2", + "bytes": 47976, + "dims": [ + 480, + 480 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 480x480 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "create-type-story", + "chosen_variant": null, + "used_in": "New work dialog: story type", + "original": { + "file": "create-type-story.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/create-type-story.png", + "sha256": "4a79ba3d9440ebc10499b704288850b66dcdd45a6c203e0a61f7b2f530407a1a", + "bytes": 1282023, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:51:05.477Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "An open book with a tiny pop-up theatre stage and curtain rising from its pages. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "An open book with a tiny pop-up theatre stage and curtain rising from its pages. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 居中. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/create-type-story.webp", + "url": "/generated/batch-01/create-type-story.webp", + "sha256": "77212795bda011a04a827d22f93084b907e48163db6ba5f694897ac0be434ae7", + "bytes": 19952, + "dims": [ + 320, + 320 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 320x320 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "create-type-character", + "chosen_variant": null, + "used_in": "New work dialog: character type", + "original": { + "file": "create-type-character.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/create-type-character.png", + "sha256": "fabbf59b09a5e872c8f9dbabcef2bbb463939707d13f66b271d31e5e3408a30e", + "bytes": 818398, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T04:51:58.669Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "An empty ornate oval portrait frame standing on a small easel, a few soft sparkles around it, no face or figure inside the frame. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.", + "actual_prompt": "An empty ornate oval portrait frame standing on a small easel, a few soft sparkles around it, no face or figure inside the frame. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 1:1 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 居中. Real alpha transparency RGBA PNG, no checkerboard or painted false transparency; no matte fringes. Transparent empty space surrounds the compact spot illustration. Square 1:1 PNG with genuine alpha transparency, no painted checkerboard or flat background. Centered compact composition with at least 15 percent transparent margins. Make important silhouettes bold enough for 160px. The die, paper, seals and book pages are entirely unmarked. No text or glyphs." + }, + "derivative": { + "path": "public/generated/batch-01/create-type-character.webp", + "url": "/generated/batch-01/create-type-character.webp", + "sha256": "3d3d491407d7479be57670eb1864950a9587bbc5b3b8bc39afc0412d7971209e", + "bytes": 22672, + "dims": [ + 320, + 320 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 320x320 (premultiplied alpha); WebP q86 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-01", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-01.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-01.png", + "sha256": "90657758022619f9877f2522a8d6a950dc1e62880abd1655f00e53327ede9c7b", + "bytes": 2552998, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:52:43.137Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: rain running down a window pane over warm out-of-focus lights. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: rain running down a window pane over warm out-of-focus lights. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-01.webp", + "url": "/generated/batch-01/work-cover-01.webp", + "sha256": "55654e81e0ee4c943f7285dfe56666c4877ce164bc19df8c55c7440ade6e5c6a", + "bytes": 77480, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-02", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-02.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-02.png", + "sha256": "47a013e6b4ef4bb0d31fbb492db3e7c07843d566d4c7f71a1b221a2538440147", + "bytes": 2535155, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:53:12.793Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: a parted deep red velvet stage curtain with a warm glow behind it. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: a parted deep red velvet stage curtain with a warm glow behind it. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-02.webp", + "url": "/generated/batch-01/work-cover-02.webp", + "sha256": "7538eb83c469ef62c20e2b339eeed08fb58a96ecbad7c2dc074d26b6dd7c3fe8", + "bytes": 59198, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-03", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-03.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-03.png", + "sha256": "de8aeeb76bc75f07f80c838670c5148ed2b3d4b2646631432a79cfbda0deb580", + "bytes": 2517790, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:53:57.995Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: a night sky with constellations drawn as dotted lines and small stars (no letters). Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: a night sky with constellations drawn as dotted lines and small stars (no letters). Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-03.webp", + "url": "/generated/batch-01/work-cover-03.webp", + "sha256": "a243867cace02b67670e77db280d5eeb86f8656bbfb72e0435ddbe2ab294e747", + "bytes": 49730, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-04", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-04.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-04.png", + "sha256": "d8a327a6d77514da2c8bdabf55bf69ad76c17d69d65d2a75836fc4cf01fddf43", + "bytes": 2954960, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:54:29.516Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: layered paper-cut leaves in teal and amber. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: layered paper-cut leaves in teal and amber. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-04.webp", + "url": "/generated/batch-01/work-cover-04.webp", + "sha256": "84b8984fd0dea6e2e6efe094fd07c38348d6a579264cddc406531a89a817b45a", + "bytes": 72158, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-05", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-05.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-05.png", + "sha256": "7026c494c9c29b93c26dd3e77f90a069b3e1107779f7af2e7c7231a29a06e0e7", + "bytes": 2521509, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:55:07.270Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: soft ink-wash waves in muted teal with a thin gold horizon. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: soft ink-wash waves in muted teal with a thin gold horizon. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-05.webp", + "url": "/generated/batch-01/work-cover-05.webp", + "sha256": "49d2766e6d30f191de196d58aab38f060a0c7c56c1ad09097c8cde5568830bbb", + "bytes": 44092, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "work-cover-06", + "chosen_variant": null, + "used_in": "Work cover choice: work cards, editor, preview panel", + "original": { + "file": "work-cover-06.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/work-cover-06.png", + "sha256": "6bbe9303fcd61f1f8a9a020000b2e8f1593e8058e8fe9bbbf209884284956168", + "bytes": 2104582, + "dims": [ + 1024, + 1536 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T04:55:34.767Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and requested size/crops; accepted", + "reference_images": [], + "prompt": "Soft painterly abstract cover background: a few glowing paper lanterns floating in warm dusk. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.", + "actual_prompt": "Soft painterly abstract cover background: a few glowing paper lanterns floating in warm dusk. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people.\n\nProduction constraints: Output one original PNG at the largest native size supported for 2:3 aspect ratio. Do not upscale. No text, letters, numerals, glyphs, signage, logos, watermarks, faces, people or human silhouettes anywhere, including reflections, photographs and pictures. Any writing requested above must be only non-glyph abstract ink smudges or blank surfaces. Safety zone: 中央保持安静,方便叠标题. Portrait 2:3, largest native supported dimensions, no enlargement. Central 50 percent of width and central 55 percent height stays low contrast and uncluttered for title overlays." + }, + "derivative": { + "path": "public/generated/batch-01/work-cover-06.webp", + "url": "/generated/batch-01/work-cover-06.webp", + "sha256": "af07293aceabc8203f8fc4cdc2a85b705a0cd68ac1795e5c9bd821f865a9ef8f", + "bytes": 41356, + "dims": [ + 600, + 900 + ], + "mode": "RGB", + "transform": "Lanczos resize 1024x1536 -> 600x900; WebP q80 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-01", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-01.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-01.png", + "sha256": "ceb1244f62457c6fe6607d1f8527e474880a2efa57964eb8cc4b6fd0ee8a2857", + "bytes": 1395768, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:21:41.968Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "An umbrella in light rain. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "An umbrella in light rain. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-01.webp", + "url": "/generated/batch-01/persona-avatar-01.webp", + "sha256": "d33dc1ee03a557e43cbc7863b31dd03ac39db9621d479863e81d6f47a10fbf35", + "bytes": 6086, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-02", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-02.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-02.png", + "sha256": "201acb19cea5960c8f44b84090ecb778830a1ce26b74666dcdc48457e70901ec", + "bytes": 1598660, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:22:17.559Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A glowing paper lantern. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A glowing paper lantern. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-02.webp", + "url": "/generated/batch-01/persona-avatar-02.webp", + "sha256": "7898a99dad8c4900492cac2a47e90bcc8dea163cf805cd8a6ec9deb02eed2104", + "bytes": 7848, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-03", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-03.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-03.png", + "sha256": "b7c887524b58e5aef3e5b072607329b82adbddaaa13aea885832c92caf49185b", + "bytes": 1684403, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:23:05.810Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A steaming teacup. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A steaming teacup. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-03.webp", + "url": "/generated/batch-01/persona-avatar-03.webp", + "sha256": "0e24b3ed31d63eb842029ba75345215c8d28fcda749db3c680f7621f26b12702", + "bytes": 9700, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-04", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-04.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-04.png", + "sha256": "2bca1e74c1a22f5b11a338687277a47770340b037d81f7159481f6acead24c4e", + "bytes": 1528019, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:23:40.553Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A small sitting fox. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A small sitting fox. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-04.webp", + "url": "/generated/batch-01/persona-avatar-04.webp", + "sha256": "3f042bf55e3d4ccc4d6a01b3b57d6cd846680bae58872323218898c0d9ea6ce0", + "bytes": 6128, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-05", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-05.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-05.png", + "sha256": "d52897880eeb3071806a658d2e1e7dbe963b85d45c286483939f6404ae5082e2", + "bytes": 1785368, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:24:29.858Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A sleeping cat curled up. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A sleeping cat curled up. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-05.webp", + "url": "/generated/batch-01/persona-avatar-05.webp", + "sha256": "363493222a22052642deea71fa9d100b80f5112fb0a8ada523c26a36e03ba4bb", + "bytes": 8452, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-06", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-06.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-06.png", + "sha256": "da249ee730e17d3bd42849490532b8f7cc0e9c0e86c08ba4e8d4f8acfa745216", + "bytes": 1896447, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:25:00.165Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A round little owl. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A round little owl. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-06.webp", + "url": "/generated/batch-01/persona-avatar-06.webp", + "sha256": "81db2e8f4e29b5dd96a56219c24b6d69090657158c7366a47ff459e08077fa37", + "bytes": 9958, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-07", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-07.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-07.png", + "sha256": "67fe0ed5bc852c8c5cc6fc653b2965083b11829cd3ee6040c5af2ec14320b4d3", + "bytes": 1385465, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:25:34.882Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A folded paper boat on a ripple. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A folded paper boat on a ripple. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-07.webp", + "url": "/generated/batch-01/persona-avatar-07.webp", + "sha256": "707439c27ab4fa24631239809260639841008d8d5d048114f586348b6e87eeb1", + "bytes": 4716, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "persona-avatar-08", + "chosen_variant": null, + "used_in": "Persona avatar choice: account page, navigation, own seat at tables", + "original": { + "file": "persona-avatar-08.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/persona-avatar-08.png", + "sha256": "8da62c6a11899b6ffdfe2bdd4c5b8180d995412731ac7327404452d60415544e", + "bytes": 1406844, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:26:06.220Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full image and 48px circular crop on dark/light; accepted", + "reference_images": [], + "prompt": "A crescent moon with one star. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.", + "actual_prompt": "A crescent moon with one star. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Single bold subject entirely within inner 70 percent diameter, simple flat storybook shapes, full opaque warm-brown #2A1D19 background reaching all corners; no circular border, no complex background. Clearly recognizable in a 48px circular crop." + }, + "derivative": { + "path": "public/generated/batch-01/persona-avatar-08.webp", + "url": "/generated/batch-01/persona-avatar-08.webp", + "sha256": "a6dc2af779adf5ec00cc353efca8ac2bb382cdf0e3f22009af17f53eaa789bf1", + "bytes": 4164, + "dims": [ + 256, + 256 + ], + "mode": "RGB", + "transform": "Lanczos resize 1254x1254 -> 256x256; WebP q85 method 6" + }, + "unused_candidates": [] + }, + { + "slot": "brand-emblem", + "chosen_variant": "a", + "used_in": "Navigation brand mark and the first-load screen", + "original": { + "file": "brand-emblem__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/brand-emblem__a.png", + "sha256": "73b30be0ccd15341fe4ebb5aecf9118e5a67c9cdeceac56a48ec73cb7d8ec370", + "bytes": 526943, + "dims": [ + 1254, + 1254 + ], + "mode": "RGBA", + "generated_at_utc": "2026-10-14T05:27:23.174Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "text prompt; continuation task-13", + "qa_status": "checked: full composition and 24px on dark/light; accepted", + "reference_images": [], + "prompt": "A simple warm emblem for an app about companionship and playing together: two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet. Flat geometric shapes in amber and cream on a transparent background, elegant, readable at 24 px. No letters, no text, no people.", + "actual_prompt": "A simple warm emblem for an app about companionship and playing together: two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet. Flat geometric shapes in amber and cream on a transparent background, elegant, readable at 24 px. No letters, no text, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Exactly two solid clean overlapping circular shapes and one tiny four-point spark at their intersection, no extra dots, no complex gradients. Emblem occupies inner 70 percent of the canvas with transparent margins, genuine alpha. Use solid amber #E3B997 and cream; clearly legible at 24px. Candidate A: horizontally overlapping left amber circle and right cream circle." + }, + "derivative": { + "path": "public/generated/batch-01/brand-emblem.png", + "url": "/generated/batch-01/brand-emblem.png", + "sha256": "ddd3f61570975c9fea7f9484c432bb069dd06f8f25d7d384dbea668961ac6052", + "bytes": 7696, + "dims": [ + 128, + 128 + ], + "mode": "RGBA", + "transform": "Lanczos resize 1254x1254 -> 128x128 (premultiplied alpha); PNG" + }, + "unused_candidates": [ + "brand-emblem__b.png" + ] + }, + { + "slot": "app-icon", + "chosen_variant": "a", + "used_in": "Browser tab icon (corners outside the rounded square made transparent)", + "original": { + "file": "app-icon__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/app-icon__a.png", + "sha256": "39c5f0e39ae44baa19476948884dd504acf694c84f70c95c9a1f575064dbe315", + "bytes": 1329310, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:29:28.345Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in composition using generated brand-emblem__a.png", + "qa_status": "checked: full composition and 32px on dark/light; accepted", + "reference_images": [ + "brand-emblem__a.png" + ], + "prompt": "The same warm emblem (two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet), centered on a rounded-square tile of deep warm brown #1E1412 with a subtle radial glow. Flat, crisp, readable at 32 px. No letters, no text, no people.", + "actual_prompt": "The same warm emblem (two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet), centered on a rounded-square tile of deep warm brown #1E1412 with a subtle radial glow. Flat, crisp, readable at 32 px. No letters, no text, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Brown #1E1412 tile fills entire square canvas; safe round-corner composition, emblem occupies inner 65 percent diameter. Two solid overlapping circles and one tiny spark only, flat crisp legible at 32px. Opaque background. Horizontally overlapping amber left circle and cream right circle, matching brand A. Reference image is the corresponding generated emblem: preserve its circles, overlap and spark shape exactly while adding the brown rounded square tile. Emblem itself remains unmodified." + }, + "derivative": { + "path": "public/generated/batch-01/favicon-64.png", + "url": "/generated/batch-01/favicon-64.png", + "sha256": "ec5bf9b048dc978ad5546945a8cb84b3807f8c0887739a3c74e7f96db4f3678c", + "bytes": 5459, + "dims": [ + 64, + 64 + ], + "mode": "RGBA", + "transform": "black corners outside the rounded square -> transparent; Lanczos resize -> 64x64 (premultiplied alpha); PNG" + }, + "unused_candidates": [ + "app-icon__b.png" + ] + }, + { + "slot": "app-icon", + "chosen_variant": "a", + "used_in": "Home-screen icon (corners filled with the tile colour)", + "original": { + "file": "app-icon__a.png", + "dropbox_path": "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/app-icon__a.png", + "sha256": "39c5f0e39ae44baa19476948884dd504acf694c84f70c95c9a1f575064dbe315", + "bytes": 1329310, + "dims": [ + 1254, + 1254 + ], + "mode": "RGB", + "generated_at_utc": "2026-10-14T05:29:28.345Z", + "tool": "built-in image_gen.imagegen", + "model": "not disclosed by tool", + "source": "built-in composition using generated brand-emblem__a.png", + "qa_status": "checked: full composition and 32px on dark/light; accepted", + "reference_images": [ + "brand-emblem__a.png" + ], + "prompt": "The same warm emblem (two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet), centered on a rounded-square tile of deep warm brown #1E1412 with a subtle radial glow. Flat, crisp, readable at 32 px. No letters, no text, no people.", + "actual_prompt": "The same warm emblem (two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet), centered on a rounded-square tile of deep warm brown #1E1412 with a subtle radial glow. Flat, crisp, readable at 32 px. No letters, no text, no people.\nProduction: one square 1:1 PNG at largest supported native size, no enlargement. No text, letters, numerals, glyphs, logos, watermarks or humans. Brown #1E1412 tile fills entire square canvas; safe round-corner composition, emblem occupies inner 65 percent diameter. Two solid overlapping circles and one tiny spark only, flat crisp legible at 32px. Opaque background. Horizontally overlapping amber left circle and cream right circle, matching brand A. Reference image is the corresponding generated emblem: preserve its circles, overlap and spark shape exactly while adding the brown rounded square tile. Emblem itself remains unmodified." + }, + "derivative": { + "path": "public/generated/batch-01/apple-touch-icon.png", + "url": "/generated/batch-01/apple-touch-icon.png", + "sha256": "4fa396920abe9214b7d85d81efb3f0d80ff8469e934d38e80ded53ce3d5b142f", + "bytes": 24750, + "dims": [ + 180, + 180 + ], + "mode": "RGB", + "transform": "corners outside the rounded square filled with the tile edge colour; Lanczos resize -> 180x180; PNG" + }, + "unused_candidates": [ + "app-icon__b.png" + ] + } + ] +} diff --git a/everwith-claude-frontend/docs/GENERATED_ASSETS.md b/everwith-claude-frontend/docs/GENERATED_ASSETS.md new file mode 100644 index 00000000..91e1fd60 --- /dev/null +++ b/everwith-claude-frontend/docs/GENERATED_ASSETS.md @@ -0,0 +1,58 @@ +# 生成素材接入记录(v2-batch-01) + +- 来源:Dropbox `/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/`(45 个素材位、59 张图;`manifest.json` sha256 `03e10cd7e985b70a2edd4fc4b3ca25ac71ee6ca6b2d35a9d3be518d59f1d533d`,`SHA256SUMS.json` sha256 `f14a47a4366fb3c58dd34a710e78ffd825ecd0830339466a3f960c4a9d1c9ed7`)。 +- 生成:GPT 内置图片工具(`built-in image_gen.imagegen`,模型名未披露);每张的实际 prompt、生成时间、QA 结论都抄在 `GENERATED_ASSETS.json` 里。 +- **权利标签:用户经 GPT 生成并提供;商用/再分发权未核实**。不是交接包的一部分;只用于这次私人交接与本地演示,不公开发布、不商用。 +- 处理:只做缩放(Lanczos)和格式转换(WebP/PNG),没有修图、调色或裁切;唯一例外是浏览器图标把圆角外的纯黑角改成透明(主屏图标则用图块边缘色填平)。原图全部先按批次自带的 `SHA256SUMS.json` 和 `manifest.json` 核对。 +- 网页副本在 `public/generated/batch-01/`(46 个文件,共 3.85 MB),随源码一起提交;`npm run assets:generated` 会逐个核对 sha256、检查代码里引用的文件都在清单里。重新生成:`python3 -I scripts/derive-generated.py --from `(需要 Pillow;不同 Pillow/libwebp 版本字节可能不同,以 JSON 记录为准)。 +- A/B 两版的素材位一律选 `__a`(两版都通过了 QA;a 版多为正面、居中构图,裁成卡片和圆形时更稳)。未用的 `__b` 留在 Dropbox,没有复制进项目。 +- 游戏海报默认改用生成封面(原交接包 480×480 海报仍同步在 `public/handoff`,在 `src/mock/catalog.ts` 里把 `poster` 改回 `${V}/…-poster.webp` 一行即可切回);悬停预览短片仍用交接包的。 + +| 素材位 | 选用 | 用在哪里 | 原图 | 网页副本 | 未用候选 | +|---|---|---|---|---|---| +| `story-last-train-cover-tall` | a | 发现页故事卡(4:5)、故事准备页海报(3:4)、最近 / 继续 / 一起玩的缩略图 | 1024×1536 · `916afdf6d0d0…` | `story-last-train-cover-tall.webp` 768×1152 · 147 KB | story-last-train-cover-tall__b.png | +| `story-last-train-cover-wide` | a | 故事详情页大图(4:3) | 1536×1024 · `5f8b1a0ab5c9…` | `story-last-train-cover-wide.webp` 1280×853 · 184 KB | story-last-train-cover-wide__b.png | +| `story-last-train-scene-waiting-room` | 单张 | 游玩页场景横幅:开场与第 1 拍 | 1536×1024 · `f5a2ad0a9b4e…` | `story-last-train-scene-waiting-room.webp` 1280×853 · 125 KB | — | +| `story-last-train-scene-corner` | 单张 | 场景横幅:第 2 拍 | 1536×1024 · `2c2ecbb9b641…` | `story-last-train-scene-corner.webp` 1280×853 · 165 KB | — | +| `story-last-train-scene-duty-room` | 单张 | 场景横幅:第 3 拍 | 1536×1024 · `73aedfb7f2bd…` | `story-last-train-scene-duty-room.webp` 1280×853 · 103 KB | — | +| `story-last-train-scene-platform-dawn` | 单张 | 场景横幅:最后一拍之后的自由行动(开放模式) | 1536×1024 · `a5817285ec76…` | `story-last-train-scene-platform-dawn.webp` 1280×853 · 163 KB | — | +| `story-promise-rain-cover-tall` | a | 发现页故事卡、故事准备页海报、缩略图 | 1024×1536 · `7ebd34d35659…` | `story-promise-rain-cover-tall.webp` 768×1152 · 240 KB | story-promise-rain-cover-tall__b.png | +| `story-promise-rain-cover-wide` | a | 故事详情页大图 | 1536×1024 · `c6520259facf…` | `story-promise-rain-cover-wide.webp` 1280×853 · 298 KB | story-promise-rain-cover-wide__b.png | +| `story-promise-rain-scene-entrance-rain` | 单张 | 场景横幅:开场与第 1 拍 | 1536×1024 · `459fae2184ac…` | `story-promise-rain-scene-entrance-rain.webp` 1280×853 · 275 KB | — | +| `story-promise-rain-scene-backstage` | 单张 | 场景横幅:第 2 拍 | 1536×1024 · `e50d3e4e50d8…` | `story-promise-rain-scene-backstage.webp` 1280×853 · 138 KB | — | +| `story-promise-rain-scene-office` | 单张 | 场景横幅:第 3 拍 | 1536×1024 · `bf0fc897ce1f…` | `story-promise-rain-scene-office.webp` 1280×853 · 111 KB | — | +| `story-promise-rain-scene-entrance-after-rain` | 单张 | 场景横幅:第 4 拍 | 1536×1024 · `ea887326d14d…` | `story-promise-rain-scene-entrance-after-rain.webp` 1280×853 · 294 KB | — | +| `backdrop-lena` | a | Lena 的舞台背景:角色页、私聊、牌桌侧栏、故事左栏、发现卡、好友列表头像底 | 1024×1536 · `b5da63d9316c…` | `backdrop-lena.webp` 768×1152 · 93 KB | backdrop-lena__b.png | +| `backdrop-arthur` | a | Arthur 的舞台背景:角色页、私聊、牌桌侧栏、故事左栏、发现卡、好友列表头像底 | 1024×1536 · `877725684588…` | `backdrop-arthur.webp` 768×1152 · 72 KB | backdrop-arthur__b.png | +| `backdrop-marcus` | a | Marcus 的舞台背景:角色页、私聊、牌桌侧栏、故事左栏、发现卡、好友列表头像底 | 1024×1536 · `db527378ef41…` | `backdrop-marcus.webp` 768×1152 · 69 KB | backdrop-marcus__b.png | +| `backdrop-nora` | a | Nora 的舞台背景:角色页、私聊、牌桌侧栏、故事左栏、发现卡、好友列表头像底 | 1024×1536 · `9c519d994bc8…` | `backdrop-nora.webp` 768×1152 · 100 KB | backdrop-nora__b.png | +| `backdrop-iris` | a | Iris 的舞台背景:角色页、私聊、牌桌侧栏、故事左栏、发现卡、好友列表头像底 | 1024×1536 · `be814c797aed…` | `backdrop-iris.webp` 768×1152 · 100 KB | backdrop-iris__b.png | +| `backdrop-generic` | 单张 | 自创角色(没有专属房间)的舞台背景 | 1024×1536 · `43f0e979fe75…` | `backdrop-generic.webp` 768×1152 · 75 KB | — | +| `game-royale-cover` | a | 游戏海报:发现页(1:1)、准备页(3:4)、一起玩 / 最近缩略图 | 1024×1536 · `955c06a04e47…` | `game-royale-cover.webp` 768×1152 · 153 KB | game-royale-cover__b.png | +| `game-splendor-cover` | a | 同上 | 1024×1536 · `98acbe94447f…` | `game-splendor-cover.webp` 768×1152 · 163 KB | game-splendor-cover__b.png | +| `game-cabo-cover` | a | 同上 | 1024×1536 · `a1d9d60fd0a3…` | `game-cabo-cover.webp` 768×1152 · 138 KB | game-cabo-cover__b.png | +| `empty-friends` | 单张 | 好友页:还没有好友 | 1254×1254 · `a22a1b2773e5…` | `empty-friends.webp` 480×480 · 43 KB | — | +| `empty-recent` | 单张 | 最近页:还没有记录 / 没有归档 | 1254×1254 · `3fc5aabb0c64…` | `empty-recent.webp` 480×480 · 31 KB | — | +| `empty-create` | 单张 | 创作页:还没有作品 | 1254×1254 · `28cd27c2e0b0…` | `empty-create.webp` 480×480 · 43 KB | — | +| `state-not-found` | 单张 | 找不到(页面、角色、故事、局、好友、作品) | 1254×1254 · `270604800fb0…` | `state-not-found.webp` 480×480 · 60 KB | — | +| `state-load-error` | 单张 | 加载失败(与“没有内容”区分) | 1254×1254 · `e960e7744fe0…` | `state-load-error.webp` 480×480 · 44 KB | — | +| `state-signin` | 单张 | 需要登录(好友页、私聊)与登录弹窗 | 1254×1254 · `449188d214ae…` | `state-signin.webp` 480×480 · 47 KB | — | +| `create-type-story` | 单张 | 新建作品弹窗:故事 | 1254×1254 · `4a79ba3d9440…` | `create-type-story.webp` 320×320 · 19 KB | — | +| `create-type-character` | 单张 | 新建作品弹窗:角色 | 1254×1254 · `fabbf59b09a5…` | `create-type-character.webp` 320×320 · 22 KB | — | +| `work-cover-01` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `906577580226…` | `work-cover-01.webp` 600×900 · 76 KB | — | +| `work-cover-02` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `47a013e6b4ef…` | `work-cover-02.webp` 600×900 · 58 KB | — | +| `work-cover-03` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `de8aeeb76bc7…` | `work-cover-03.webp` 600×900 · 49 KB | — | +| `work-cover-04` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `d8a327a6d775…` | `work-cover-04.webp` 600×900 · 70 KB | — | +| `work-cover-05` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `7026c494c9c2…` | `work-cover-05.webp` 600×900 · 43 KB | — | +| `work-cover-06` | 单张 | 作品封面候选:作品卡片、编辑页 | 1024×1536 · `6bbe9303fcd6…` | `work-cover-06.webp` 600×900 · 40 KB | — | +| `persona-avatar-01` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `ceb1244f6245…` | `persona-avatar-01.webp` 256×256 · 6 KB | — | +| `persona-avatar-02` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `201acb19cea5…` | `persona-avatar-02.webp` 256×256 · 8 KB | — | +| `persona-avatar-03` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `b7c887524b58…` | `persona-avatar-03.webp` 256×256 · 9 KB | — | +| `persona-avatar-04` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `2bca1e74c1a2…` | `persona-avatar-04.webp` 256×256 · 6 KB | — | +| `persona-avatar-05` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `d52897880eeb…` | `persona-avatar-05.webp` 256×256 · 8 KB | — | +| `persona-avatar-06` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `da249ee730e1…` | `persona-avatar-06.webp` 256×256 · 10 KB | — | +| `persona-avatar-07` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `67fe0ed5bc85…` | `persona-avatar-07.webp` 256×256 · 5 KB | — | +| `persona-avatar-08` | 单张 | Persona 头像候选:账号页、导航、自己的牌桌席位 | 1254×1254 · `8da62c6a1189…` | `persona-avatar-08.webp` 256×256 · 4 KB | — | +| `brand-emblem` | a | 左侧导航品牌标记、首次加载画面 | 1254×1254 · `73b30be0ccd1…` | `brand-emblem.png` 128×128 · 8 KB | brand-emblem__b.png | +| `app-icon` | a | 浏览器标签图标 / 主屏图标 | 1254×1254 · `39c5f0e39ae4…` | `favicon-64.png` 64×64 · 5 KB | app-icon__b.png | +| `app-icon` | a | 浏览器标签图标 / 主屏图标 | 1254×1254 · `39c5f0e39ae4…` | `apple-touch-icon.png` 180×180 · 24 KB | app-icon__b.png | diff --git a/everwith-claude-frontend/docs/IMAGE_GENERATION_REQUESTS.md b/everwith-claude-frontend/docs/IMAGE_GENERATION_REQUESTS.md new file mode 100644 index 00000000..bbd2a298 --- /dev/null +++ b/everwith-claude-frontend/docs/IMAGE_GENERATION_REQUESTS.md @@ -0,0 +1,649 @@ +# 图片生成需求单 v2(给 GPT) + +> **状态(2026-10-14):batch-01 已全部收到并接入。** 45 个素材位 / 59 张图,逐个与批次自带的 `SHA256SUMS.json`、`manifest.json` 核对一致;A/B 两版一律选 `__a`。每张图用在哪里、原图与网页副本的 sha256、实际 prompt 见 `GENERATED_ASSETS.md` / `GENERATED_ASSETS.json`。下面是需求原文,保留不改,供追溯。 + +共 **45 个素材位**(P0 4 · P1 14 · P2 9 · P3 18),按候选数算约 **59 张图**。每条都有可以直接粘贴的英文 prompt;同一份清单的机器可读版本是 `IMAGE_GENERATION_REQUESTS.json`。 + +## 现在视觉上不合适的地方(这批图要解决的) + +1. 故事《末班车之后》完全没有图:发现页卡片是书本图标,详情/准备页是空渐变,游玩页场景横幅是一块灰蓝色。 +2. 故事《雨中的约定》借用了不相干的图:封面和后台/办公室场景用的是 Lena 的私人书房,“剧院门口淋雨”用的是一张现代高楼休息室,和 1920 年代剧院对不上。 +3. 三款游戏的封面其实是 480×480 的首页预览视频首帧(棋盘截图),在发现页和准备页上偏糊、也不像封面。交接包里的候选封面你没有接受过,所以没用。 +4. 角色页、私聊、牌桌侧栏、故事左栏里,Live2D 角色背后只有一层纯色渐变,角色页右侧还有大片空白。 +5. 好友、最近、创作、找不到内容、加载失败等状态只有一行文字,页面大面积空。 +6. 用户自建的角色/故事没有封面,账号页的 Persona 没有头像,品牌标记只是 CSS 画的字母。 + +## 交付方式(请让 GPT 照这个放) + +- Dropbox 目录:`/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/` +- 文件名就是素材 ID:`.png`;条目写“候选 2 张”的,用 `__a.png`、`__b.png`。 +- 尺寸:按条目的**比例**出图,用工具原生的最大尺寸即可(例如横 1536×1024、竖 1024×1536、方 1024×1024),**不要放大**;裁切由我来做,所以请遵守每条的“安全区”。 +- 透明背景:条目标“透明”的出透明 PNG;做不到就用纯色 #1B1311 背景。 +- 同目录放一个 `manifest.md`(或 `manifest.json`),每张图一行:ID、文件名、工具/模型名、生成日期、实际使用的 prompt(如有改动)、备注。 +- 默认只用文字 prompt。附交接包里的图做风格参考,等于把私人素材上传给第三方,由你决定。 + +## 全部素材共同的规则 + +- 不要任何文字、字母、数字、招牌字、水印、Logo(标题、点数、金额、能力说明都由代码绘制)。 +- 场景和背景里不要人物(五位角色只能用冻结的 source/version 素材,生成图里出现人会和选角、Live2D 冲突)。 +- 不模仿任何已出版桌游的盒面/卡面、真实赌场或铁路公司品牌。 +- 风格向现有素材靠拢:暖琥珀/黄铜色灯光 × 冷蓝青夜色、半写实绘画感;游戏 CABO 那张是水粉感童话插画。 +- 权利记录口径:“用户经 GPT 生成并提供;商用/再分发权未核实”。 + +## 一览 + +| 优先级 | ID | 比例 | 候选 | 透明 | 用在哪里 | +|---|---|---|---|---|---| +| P0 | `story-last-train-cover-tall` | 2:3 | 2 | | 发现页故事卡片(裁成 4:5)、准备页海报(裁成 3:4)、最近记录图标 | +| P0 | `story-last-train-cover-wide` | 3:2 | 2 | | 故事详情页大图(4:3 区域) | +| P0 | `story-last-train-scene-waiting-room` | 3:2 | 1 | | 游玩页场景横幅:开场与第 1–2 拍(会被裁成约 5:1 的扁条) | +| P1 | `story-last-train-scene-corner` | 3:2 | 1 | | 第 2 拍(角落里的旅人走过来) | +| P0 | `story-last-train-scene-duty-room` | 3:2 | 1 | | 第 3 拍(登记、锁进值班室、天快亮) | +| P1 | `story-last-train-scene-platform-dawn` | 3:2 | 1 | | 结尾 / 开放模式 | +| P1 | `story-promise-rain-cover-tall` | 2:3 | 2 | | 发现页故事卡片、准备页海报、最近记录图标 | +| P1 | `story-promise-rain-cover-wide` | 3:2 | 2 | | 故事详情页大图 | +| P1 | `story-promise-rain-scene-entrance-rain` | 3:2 | 1 | | 开场与第 1 拍(门口、名单被划掉) | +| P1 | `story-promise-rain-scene-backstage` | 3:2 | 1 | | 第 2 拍(后台签到簿) | +| P1 | `story-promise-rain-scene-office` | 3:2 | 1 | | 第 3 拍(经理办公室、沾墨的钥匙) | +| P1 | `story-promise-rain-scene-entrance-after-rain` | 3:2 | 1 | | 第 4 拍(雨小了、灯重新亮起、开场铃响) | +| P1 | `backdrop-lena` | 2:3 | 2 | | Lena(剧院调查员):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P1 | `backdrop-arthur` | 2:3 | 2 | | Arthur(剧院经理):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P1 | `backdrop-marcus` | 2:3 | 2 | | Marcus(舞台监督):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P1 | `backdrop-nora` | 2:3 | 2 | | Nora(好奇的同伴):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P1 | `backdrop-iris` | 2:3 | 2 | | Iris(细心的倾听者):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P1 | `backdrop-generic` | 2:3 | 1 | | 创作的角色(通用):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) | +| P2 | `game-royale-cover` | 2:3 | 2 | | 发现页游戏卡片(居中裁成正方形)、准备页海报(3:4)、“一起玩”弹窗 | +| P2 | `game-splendor-cover` | 2:3 | 2 | | 同上 | +| P2 | `game-cabo-cover` | 2:3 | 2 | | 同上 | +| P2 | `empty-friends` | 1:1 | 1 | 是 | 好友页空状态(还没有好友 / 需要登录) | +| P2 | `empty-recent` | 1:1 | 1 | 是 | 最近页空状态 | +| P2 | `empty-create` | 1:1 | 1 | 是 | 创作页空状态 | +| P2 | `state-not-found` | 1:1 | 1 | 是 | 找不到内容 / 局不存在 | +| P2 | `state-load-error` | 1:1 | 1 | 是 | 加载失败(不是“没有内容”) | +| P2 | `state-signin` | 1:1 | 1 | 是 | 需要登录的提示 | +| P3 | `create-type-story` | 1:1 | 1 | 是 | 新建作品弹窗里的“故事”类型卡片 | +| P3 | `create-type-character` | 1:1 | 1 | 是 | 新建作品弹窗里的“角色”类型卡片 | +| P3 | `work-cover-01` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `work-cover-02` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `work-cover-03` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `work-cover-04` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `work-cover-05` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `work-cover-06` | 2:3 | 1 | | 用户自建作品的默认封面(作品卡片、试演、最近) | +| P3 | `persona-avatar-01` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-02` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-03` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-04` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-05` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-06` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-07` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `persona-avatar-08` | 1:1 | 1 | | 账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) | +| P3 | `brand-emblem` | 1:1 | 2 | 是 | 左侧导航的品牌标记、加载页 | +| P3 | `app-icon` | 1:1 | 2 | | 浏览器标签图标 / 桌面图标 | + +## 故事《末班车之后》 + +### `story-last-train-cover-tall` · P0 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:发现页故事卡片(裁成 4:5)、准备页海报(裁成 3:4)、最近记录图标 +- 现状:没有图,显示书本图标 +- 安全区:手提箱和长椅放在画面中央的正方形里 + +**Prompt** + +```text +Portrait 2:3 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. In the lower center a worn vintage brown leather suitcase with brass corners stands upright beside an empty wooden bench; a rain-soaked paper luggage tag hangs from its handle, its ink run and unreadable. Two old pendant lamps pool warm amber light on the bench and the terrazzo floor; behind them a tall window shows a wet, empty platform under deep blue night, rain streaks on the glass and a distant red signal light. Quiet, gentle mystery: a little lonely but safe. Keep the suitcase and the bench inside the central square of the frame. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-last-train-cover-wide` · P0 · 3:2(≥ 1536×1024) · 候选 2 张 + +- 用在:故事详情页大图(4:3 区域) +- 现状:没有图,显示书本图标 +- 安全区:主体在中间 2/3 宽度内 + +**Prompt** + +```text +Landscape 3:2 illustration of the waiting room of a small old railway station on a rainy night, just after the last train has left. Centered: a worn vintage brown leather suitcase with brass corners standing beside an empty wooden bench, a rain-soaked paper luggage tag with run, unreadable ink. Warm amber pendant lamps; tall windows on both sides show a wet empty platform under deep blue night, rain streaks, a distant red signal light. Quiet, gentle mystery, a little lonely but safe. Keep the suitcase and bench within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-last-train-scene-waiting-room` · P0 · 3:2(≥ 1536×1024) + +- 用在:游玩页场景横幅:开场与第 1–2 拍(会被裁成约 5:1 的扁条) +- 现状:纯色渐变 +- 安全区:关键物体都在画面垂直方向 35%–70% 的中段 + +**Prompt** + +```text +Wide landscape 3:2 view of the waiting room of a small old railway station on a rainy night, just after the last train has left, at about 1 a.m.: rows of wooden benches, a shuttered ticket window, a round wall clock with plain hands and no numerals, a small lost-and-found counter with a brass bell and a closed logbook, and an old brown leather suitcase standing beside a bench in the middle ground. Only a few warm lamps are on; the rest falls into soft blue shadow; rain on tall windows, a wet platform glimpsed outside. Calm, slightly mysterious slice-of-life mood; an ordinary working station, not abandoned. Keep all key objects in the middle horizontal band of the frame. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-last-train-scene-corner` · P1 · 3:2(≥ 1536×1024) + +- 用在:第 2 拍(角落里的旅人走过来) +- 现状:纯色渐变 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of a quiet corner of the waiting room of a small old railway station on a rainy night, just after the last train has left: an old cast-iron radiator, a single bench with a folded woollen scarf and a paper cup of tea left on it, a dim wall lamp, a rain-streaked window with the blue platform lights beyond. Tender, hesitant mood, as if someone has just stood up. Keep the bench and the scarf in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-last-train-scene-duty-room` · P0 · 3:2(≥ 1536×1024) + +- 用在:第 3 拍(登记、锁进值班室、天快亮) +- 现状:纯色渐变 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of the small duty room / lost-property office of the same old railway station just before dawn: a tidy wooden desk with an open logbook and a fountain pen (handwriting not legible), a metal flashlight, a wall board with several keys on hooks, and the old brown leather suitcase placed on a shelf inside a wire-mesh locker. A green-shaded desk lamp gives warm light while the window shows the sky turning pale blue-grey at dawn. Orderly, calm, reassuring. Key objects in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, pale dawn light meeting warm lamplight, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-last-train-scene-platform-dawn` · P1 · 3:2(≥ 1536×1024) + +- 用在:结尾 / 开放模式 +- 现状:纯色渐变 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of an empty small-town railway platform at dawn after a rainy night: wet concrete reflecting a pale peach-and-blue sky, rails curving away into light mist, a single wooden bench, an illuminated departure board glowing softly with abstract light rows only (no characters), overhead wires catching the first light. Hopeful, quiet morning. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, pale dawn light meeting warm lamplight, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +## 故事《雨中的约定》 + +### `story-promise-rain-cover-tall` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:发现页故事卡片、准备页海报、最近记录图标 +- 现状:借用 Lena 的书房插画(不是剧院) +- 安全区:门口和讲台在中央正方形内 + +**Prompt** + +```text +Portrait 2:3 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain. A marquee lined with warm light bulbs, a few of them flickering, carries no lettering at all. Under the awning: a red velvet rope, a wooden doorman's lectern with a clipboard guest list whose writing is not legible, and a dark umbrella leaning against it. Wet pavement and puddles reflect gold and crimson light; rain falls in visible streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and the lectern inside the central square. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-promise-rain-cover-wide` · P1 · 3:2(≥ 1536×1024) · 候选 2 张 + +- 用在:故事详情页大图 +- 现状:借用 Lena 的书房插画 +- 安全区:主体在中间 2/3 宽度内 + +**Prompt** + +```text +Landscape 3:2 illustration of the entrance of an old ornate 1920s theatre at night in heavy rain: bulb-lined marquee with no lettering, glass doors glowing warm, a red velvet rope, a doorman's lectern with an illegible guest list on a clipboard, a dark umbrella, gold and crimson reflections in puddles, rain streaks against deep blue night. Mystery with a touch of romance. Keep the doorway and lectern within the middle two-thirds of the width. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-promise-rain-scene-entrance-rain` · P1 · 3:2(≥ 1536×1024) + +- 用在:开场与第 1 拍(门口、名单被划掉) +- 现状:借用了一张现代高楼休息室(与 1920 年代剧院不符) +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of the entrance of an old ornate 1920s theatre at night in heavy rain, seen from across a wet cobblestone street: a glowing bulb marquee with no lettering, glass doors with warm light behind them, a velvet rope and a doorman's lectern under the awning, an umbrella, reflections in puddles, rain streaks. Mysterious but inviting. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-promise-rain-scene-backstage` · P1 · 3:2(≥ 1536×1024) + +- 用在:第 2 拍(后台签到簿) +- 现状:借用 Lena 的书房插画 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of a quiet backstage corridor of an old ornate 1920s theatre at night: exposed brick wall, a costume rack with velvet garments, coiled ropes and a sandbag, a stage door with a small round window, and a wooden desk under a green-shaded lamp holding an open sign-in book (handwriting not legible) and a pen. A small locked wooden key cabinet hangs on the wall. Hushed, intimate, faintly suspenseful; muted crimson and brass. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-promise-rain-scene-office` · P1 · 3:2(≥ 1536×1024) + +- 用在:第 3 拍(经理办公室、沾墨的钥匙) +- 现状:借用 Lena 的书房插画 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of the theatre manager's small office at night, the door half open: a heavy wooden desk with a single brass key whose paper tag is smudged with dark ink, an inkwell, a neat stack of programs with no readable printing, a framed photograph too far away to make out, rain on the window behind, a warm desk lamp. Calm, a little melancholy, quietly revealing. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `story-promise-rain-scene-entrance-after-rain` · P1 · 3:2(≥ 1536×1024) + +- 用在:第 4 拍(雨小了、灯重新亮起、开场铃响) +- 现状:借用现代休息室图 +- 安全区:中段横条 + +**Prompt** + +```text +Wide landscape 3:2 view of the same entrance of an old ornate 1920s theatre as the rain eases: last drops in puddles, the bulb marquee now fully and steadily lit (no lettering), the glass doors open and spilling warm golden light, a closed umbrella hanging on a rope post, the faint glow of the auditorium beyond. Relieved, warm ending. Key elements in the middle horizontal band. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +## 角色背景(Live2D 背后) + +### `backdrop-lena` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:Lena(剧院调查员):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of an investigator's study in an old theatre district at night: a tall arched window on the left with rain and soft city lights, bookshelves and neatly tied case folders on the right, a brass desk lamp glowing, a pinned street map with red string on a cork board (no writing). This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `backdrop-arthur` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:Arthur(剧院经理):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of a theatre manager's office above a grand old stage: dark wood panelling, deep red velvet curtains, a brass key board, leather ledgers, framed playbills seen from too far to read, and an interior window looking down on a softly lit, empty stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `backdrop-marcus` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:Marcus(舞台监督):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of a stage manager's prompt corner backstage: a cue-light panel with small coloured bulbs, a headset hanging on a hook, ropes and pulleys of the fly system rising into darkness, painted scenery flats leaning against the wall, a clamp work lamp with warm light against the dark stage. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `backdrop-nora` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:Nora(好奇的同伴):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of a curious explorer's attic workshop at golden hour: a round window with low warm sun, old maps and a globe, potted plants, small brass gadgets and a telescope, open notebooks with sketches only (no writing); warm and inviting. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm golden-hour sunlight with soft teal shadows, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `backdrop-iris` · P1 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:Iris(细心的倾听者):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of a quiet reading nook by a rainy window at dusk: a window seat with a soft knitted blanket, a tea set, a small stack of letters and a fountain pen (no readable writing), candle light meeting blue-grey dusk; calm and gentle. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +### `backdrop-generic` · P1 · 2:3(≥ 1024×1536) + +- 用在:创作的角色(通用):角色页舞台、私聊左侧舞台、牌桌侧栏、故事左栏(作为 Live2D 后面的背景) +- 现状:纯色渐变 +- 安全区:中间一半宽度保持干净、虚化(人物会叠在中间) + +**Prompt** + +```text +Portrait 2:3 background illustration of a neutral cozy room at night with a window, warm lamps and soft bokeh lights, no specific profession; suits any character. This is a background plate: an animated character will be composited in the center, so keep the central half of the width calm, softly out of focus and low in contrast (a wall, window glow or gentle bokeh), with the interesting detail toward the edges and the top. Eye-level camera, shallow depth of field. Style: painterly cinematic digital illustration, semi-realistic, fine brushwork, soft volumetric light, warm amber and brass lamplight against cool teal-blue night, rich but muted colors, high detail. No people, no faces, no human silhouettes. No text, letters, numbers, readable writing, signage, logos, watermarks or UI elements. Not photographic, not cartoon or chibi, no horror. +``` + +## 游戏封面(候选) + +### `game-royale-cover` · P2 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:发现页游戏卡片(居中裁成正方形)、准备页海报(3:4)、“一起玩”弹窗 +- 现状:480×480 的首页预览视频首帧(棋盘截图,偏糊) +- 安全区:主体在中央正方形内 + +**Prompt** + +```text +Original key art for a casino dice board game. A stylized night boulevard of six glamorous fictional casino buildings in black-and-gold art-deco style with neon accents, seen from slightly above; in the foreground on green felt, a handful of colourful six-sided dice with white pips tumbling mid-roll beside a fan of fictional pastel banknotes decorated only with abstract guilloche patterns; gold light bokeh. Rich black, gold, emerald and ruby; polished premium tabletop-game illustration. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. No real casino names, city signs or real currency designs. +``` + +### `game-splendor-cover` · P2 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:同上 +- 现状:同上 +- 安全区:主体在中央正方形内 + +**Prompt** + +```text +Original key art evoking the Renaissance gem trade. A merchant's walnut table by an arched stone window overlooking a Renaissance harbour city at golden hour; on a deep velvet cloth lie piles of cut and uncut gemstones — emerald green, sapphire blue, ruby red, diamond white and onyx black — with a few plain gold tokens, a brass balance scale and a closed leather ledger tied with a ribbon. Oil-painting look, warm light, rich textures. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. +``` + +### `game-cabo-cover` · P2 · 2:3(≥ 1024×1536) · 候选 2 张 + +- 用在:同上 +- 现状:同上 +- 安全区:主体在中央正方形内 + +**Prompt** + +```text +Original whimsical storybook illustration for a light card game. Four playing cards fan out in the center against a deep forest-green background with a subtle leaf pattern; each card shows a cute creature in lush leaves, flowers and mushrooms — a fox, a small octopus, an owl and a rabbit — and a fifth card lies face down showing an ornamental pattern of overlapping green, teal and yellow leaf scales. Bright friendly colours, gouache-like texture, clean shapes, soft shading, children's-book quality. The cards carry no numbers, letters or symbols. Main subject inside the central square of a portrait 2:3 frame. No text, letters or numbers anywhere (titles and values are drawn by the app), no logos or publisher marks, no watermarks, no people; do not imitate any published board game's box or card art. No scary creatures. +``` + +## 空状态 / 错误状态插画 + +### `empty-friends` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:好友页空状态(还没有好友 / 需要登录) +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +two cups of tea side by side on a windowsill, gentle rain outside the window, a small potted plant; cozy and inviting. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `empty-recent` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:最近页空状态 +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +a small neat stack of blank ticket stubs, a ribbon bookmark and a single die, loosely arranged. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `empty-create` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:创作页空状态 +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +an open sketchbook with a pencil resting on it and a small desk lamp glowing beside it; hopeful. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `state-not-found` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:找不到内容 / 局不存在 +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +a small wooden door slightly ajar with a soft lamp glow behind it and a few fallen leaves on the step; calm, not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `state-load-error` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:加载失败(不是“没有内容”) +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +a short string of warm fairy lights, slightly tangled, with one bulb dimmed; gentle "something went wrong", not alarming. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `state-signin` · P2 · 1:1(≥ 1024×1024) · 透明 + +- 用在:需要登录的提示 +- 现状:只有文字,页面大面积空白 +- 安全区:主体居中,四周留白 + +**Prompt** + +```text +a small brass key resting on a folded letter sealed with a blank wax seal (no letters on the seal). Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +## 创作工作室 + +### `create-type-story` · P3 · 1:1(≥ 1024×1024) · 透明 + +- 用在:新建作品弹窗里的“故事”类型卡片 +- 现状:只有图标 +- 安全区:居中 + +**Prompt** + +```text +An open book with a tiny pop-up theatre stage and curtain rising from its pages. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `create-type-character` · P3 · 1:1(≥ 1024×1024) · 透明 + +- 用在:新建作品弹窗里的“角色”类型卡片 +- 现状:只有图标 +- 安全区:居中 + +**Prompt** + +```text +An empty ornate oval portrait frame standing on a small easel, a few soft sparkles around it, no face or figure inside the frame. Minimal line-and-wash spot illustration on a transparent background (if transparency is impossible, use a flat #1B1311 background). Palette limited to warm amber #E3B997, terracotta #CB5367 and muted teal #4B94B5 with cream highlights; thin confident lines, lots of negative space, readable at 160 px wide. No text, no letters, no numbers, no people. +``` + +### `work-cover-01` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: rain running down a window pane over warm out-of-focus lights. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +### `work-cover-02` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: a parted deep red velvet stage curtain with a warm glow behind it. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +### `work-cover-03` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: a night sky with constellations drawn as dotted lines and small stars (no letters). Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +### `work-cover-04` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: layered paper-cut leaves in teal and amber. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +### `work-cover-05` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: soft ink-wash waves in muted teal with a thin gold horizon. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +### `work-cover-06` · P3 · 2:3(≥ 1024×1536) + +- 用在:用户自建作品的默认封面(作品卡片、试演、最近) +- 现状:没有封面 +- 安全区:中央保持安静,方便叠标题 + +**Prompt** + +```text +Soft painterly abstract cover background: a few glowing paper lanterns floating in warm dusk. Calm center area for an overlaid title, works at small sizes, warm amber, terracotta and muted teal palette. No text, letters, numbers, logos or people. +``` + +## 我的 Persona 头像 + +### `persona-avatar-01` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +An umbrella in light rain. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-02` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A glowing paper lantern. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-03` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A steaming teacup. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-04` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A small sitting fox. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-05` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A sleeping cat curled up. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-06` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A round little owl. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-07` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A folded paper boat on a ripple. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +### `persona-avatar-08` · P3 · 1:1(≥ 1024×1024) + +- 用在:账号页“我的 Persona”可选头像(不是人物,避免和角色混淆) +- 现状:没有头像 +- 安全区:圆形裁切安全 + +**Prompt** + +```text +A crescent moon with one star. Flat storybook illustration inside a circle-safe composition (the image will be cropped to a circle), deep warm brown background #2A1D19, simple bold shapes in amber, terracotta, teal and cream, readable at 48 px. No text, no letters, no people. +``` + +## 品牌(仅候选,由你决定) + +### `brand-emblem` · P3 · 1:1(≥ 1024×1024) · 透明 · 候选 2 张 + +- 用在:左侧导航的品牌标记、加载页 +- 现状:CSS 画的字母 e +- 安全区:居中 + +**Prompt** + +```text +A simple warm emblem for an app about companionship and playing together: two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet. Flat geometric shapes in amber and cream on a transparent background, elegant, readable at 24 px. No letters, no text, no people. +``` + +### `app-icon` · P3 · 1:1(≥ 1024×1024) · 候选 2 张 + +- 用在:浏览器标签图标 / 桌面图标 +- 现状:没有 +- 安全区:居中,四周留出圆角安全区 + +**Prompt** + +```text +The same warm emblem (two overlapping circles forming a soft lantern-like glow with a tiny spark where they meet), centered on a rounded-square tile of deep warm brown #1E1412 with a subtle radial glow. Flat, crisp, readable at 32 px. No letters, no text, no people. +``` + +## 不需要生成的 + +- 五位角色的立绘、头像、Live2D:必须用冻结素材。 +- 三款游戏的卡面/板面:Royale 赌场 atlas、Splendor 卡面/贵族/宝石、CABO 14 张点数插画都已在交接包里。 + +## 图到了之后我会做什么 + +1. 从 `generated-assets/batch-01/` 取图,放进项目的 `public/generated/batch-01/`(不混入哈希锁定的交接包素材),生成 `docs/GENERATED_ASSETS.json`:路径、字节数、sha256、对应 prompt、工具/模型、日期、权利标签;候选图挑一张,没选中的也记录在案。 +2. 接入:两个故事的封面与逐拍场景(故事数据里给每一拍指定场景)、角色背景(角色页、私聊、牌桌侧栏、故事左栏,人物居中叠在上面)、空/错误状态插画;游戏封面先作为候选,你确认后再设为默认。 +3. P3 需要加小功能:作品默认封面可选、Persona 头像选择、品牌标记——你确认要哪些再做。 +4. 加 `assets:generated:check` 校验哈希;重拍截图、跑四宽度矩阵和全部测试,更新 `ACCEPTANCE.md`。 diff --git a/everwith-claude-frontend/e2e/art.spec.ts b/everwith-claude-frontend/e2e/art.spec.ts new file mode 100644 index 00000000..1b808440 --- /dev/null +++ b/everwith-claude-frontend/e2e/art.spec.ts @@ -0,0 +1,146 @@ +/** + * Generated art (user-provided GPT batch v2-batch-01 — rights unverified, see + * docs/GENERATED_ASSETS.json): every picture used from it really loads where it is shown, + * story scenes follow the committed beats, the persona picture and work cover choices + * persist and show up elsewhere, and with the “assets missing” scenario every one of + * them disappears cleanly (no broken image, no other character's face). + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {settle, shot, writeJSON} from './helpers' + +type Seen = Map +/** Generated images inside the viewport (lazy images further down are not expected to have loaded). */ +const generatedImages = (page: Page) => page.evaluate(() => [...document.images] + .filter(i => (i.currentSrc || i.src).includes('/generated/')) + .filter(i => { const r = i.getBoundingClientRect(); return r.width > 0 && r.bottom > 0 && r.top < innerHeight }) + .map(i => ({src: new URL(i.currentSrc || i.src).pathname, ok: i.complete && i.naturalWidth > 0}))) +const scrollMain = (page: Page, to: 'end' | 'top') => page.locator('#main').evaluate((m, where) => m.scrollTo(0, where === 'end' ? m.scrollHeight : 0), to) +async function collect(page: Page, seen: Seen) { + await settle(page) + for (const r of await generatedImages(page)) seen.set(r.src, (seen.get(r.src) ?? true) && r.ok) +} +/** Images that finished loading with nothing to show (a broken-image icon). */ +const brokenImages = (page: Page) => page.evaluate(() => [...document.images].filter(i => i.complete && i.naturalWidth === 0).map(i => i.getAttribute('src'))) + +test('generated art loads where it is used; story scenes follow the beats', async ({page}) => { + const seen: Seen = new Map() + await page.goto('/#/discover') + await expect(page.locator('.ew-card').first()).toBeVisible() + await collect(page, seen) + await shot(page, 'art-discover') + await scrollMain(page, 'end') + await collect(page, seen) + await scrollMain(page, 'top') + await page.goto('/#/discover?tab=games') + await expect(page.locator('.ew-card.is-game')).toHaveCount(3) + await collect(page, seen) + for (const id of ['official-arthur', 'official-iris']) { + await page.goto(`/#/characters/${id}?v=1`) + await expect(page.locator('.ew-character-stage .ew-backdrop img')).toHaveAttribute('src', `/generated/batch-01/backdrop-${id.slice(9)}.webp`) + await collect(page, seen) + } + for (const id of ['story-promise-rain', 'story-last-train']) { + await page.goto(`/#/stories/${id}?v=1`) + await expect(page.locator('.ew-story-cover img')).toHaveAttribute('src', `/generated/batch-01/${id}-cover-wide.webp`) + await collect(page, seen) + } + await shot(page, 'art-story-detail') + + // Scenes: opening → beat 1 → beat 2 → beat 3 → a free turn in open mode (ending place). + await page.goto('/#/stories/story-last-train/prepare?v=1') + await expect(page.locator('.ew-prepare-poster img')).toHaveAttribute('src', '/generated/batch-01/story-last-train-cover-tall.webp') + await page.getByTestId('story-start').click() + await expect(page.getByTestId('story-play')).toBeVisible() + const scene = page.locator('.ew-story-scene') + await expect(scene).toHaveAttribute('data-scene', 'waiting-room') + await expect(scene.locator('.ew-story-scene-label')).toHaveText('候车室 · 深夜') + await collect(page, seen) + const order: string[] = [] + for (const expected of ['waiting-room', 'corner', 'duty-room', 'platform-dawn']) { + await page.getByTestId('story-continue-btn').click() + await expect(page.getByTestId('story-pending')).toBeHidden({timeout: 15_000}) + await expect(scene).toHaveAttribute('data-scene', expected) + await expect(scene.locator('img')).toHaveAttribute('src', new RegExp(`scene-${expected}\\.webp$`)) + order.push(expected) + await collect(page, seen) + } + await shot(page, 'art-story-scene-dawn') + + await page.goto('/#/recent') + await expect(page.getByTestId('record').first().locator('.ew-record-icon img')).toHaveAttribute('src', '/generated/batch-01/story-last-train-cover-tall.webp') + await collect(page, seen) + for (const [path, art] of [['/#/friends', 'signin'], ['/#/create', 'create'], ['/#/no-such-page', 'not-found']] as const) { + await page.goto(path) + await expect(page.locator(`.ew-empty .ew-spot[data-art="${art}"]`)).toBeVisible() + await collect(page, seen) + } + await page.goto('/#/create') + await page.getByTestId('create-new').click() + await expect(page.locator('dialog[open] .ew-type-art')).toHaveCount(2) + await collect(page, seen) + await shot(page, 'art-create-types') + + const icon = await page.request.get('/generated/batch-01/favicon-64.png') + expect(icon.status()).toBe(200) + expect(await page.locator('link[rel="icon"]').getAttribute('href')).toBe('/generated/batch-01/favicon-64.png') + const bad = [...seen].filter(([, ok]) => !ok).map(([src]) => src) + writeJSON('generated-art', {seen: [...seen.keys()].sort(), count: seen.size, notLoaded: bad, sceneOrder: order}) + expect(bad).toEqual([]) + expect(seen.size).toBeGreaterThanOrEqual(20) + for (const must of ['brand-emblem.png', 'backdrop-arthur.webp', 'game-splendor-cover.webp', 'story-promise-rain-cover-wide.webp', 'story-last-train-scene-platform-dawn.webp', 'state-not-found.webp', 'create-type-story.webp']) + expect([...seen.keys()].some(src => src.endsWith(must)), must).toBe(true) +}) + +test('persona picture and work cover: chosen, kept after reload, shown elsewhere', async ({page}) => { + await page.goto('/#/account') + await page.getByLabel('称呼').fill('小雨') + await page.locator('[data-avatar="persona-avatar-06"]').click() + await page.getByRole('button', {name: '保存 Persona'}).click() + await expect(page.locator('.ew-nav-avatar img')).toHaveAttribute('src', '/generated/batch-01/persona-avatar-06.webp') + await page.reload() + await expect(page.locator('[data-avatar="persona-avatar-06"]')).toHaveAttribute('aria-pressed', 'true') + await shot(page, 'art-persona-avatar') + await page.goto('/#/games/cabo/prepare') + await expect(page.locator('.ew-seat-row').first().locator('img')).toHaveAttribute('src', '/generated/batch-01/persona-avatar-06.webp') + await expect(page.locator('.ew-prepare-poster img')).toHaveAttribute('src', '/generated/batch-01/game-cabo-cover.webp') + + await page.goto('/#/create') + await page.getByTestId('create-new').click() + await page.getByTestId('type-character').click() + await page.getByTestId('work-name').fill('雨夜来信') + await page.getByTestId('work-create').click() + await expect(page.getByTestId('work-editor')).toBeVisible() + await expect(page.locator('[data-testid="cover-picker"] [aria-pressed="true"]')).toHaveCount(1) // a preset cover from the start + await page.locator('[data-cover="work-cover-05"]').click() + await expect(page.getByTestId('sync-status')).toHaveText('草稿已同步', {timeout: 10_000}) + await shot(page, 'art-work-cover-picker') + await page.goto('/#/create') + await expect(page.getByTestId('work-card').first().locator('.ew-work-cover img')).toHaveAttribute('src', '/generated/batch-01/work-cover-05.webp') + await shot(page, 'art-work-cards') +}) + +test('assets missing: generated art drops out cleanly — no broken image, no substitute face', async ({page}) => { + await page.goto('/#/discover') + await page.evaluate(() => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify({'assets.missing': true}))) + const pages = ['/#/discover', '/#/discover?tab=games', '/#/characters/official-nora?v=1', '/#/stories/story-promise-rain?v=1', '/#/friends', '/#/create', '/#/account'] + const report: Record = {} + for (const path of pages) { + await page.goto(path) + await page.reload() + await settle(page) + const broken = await brokenImages(page) + const generated = await generatedImages(page) + report[path] = {broken, generatedShown: generated.filter(g => g.ok).length} + expect(broken, path).toEqual([]) + expect(generated.filter(g => g.ok), path).toEqual([]) + } + await expect(page.locator('.ew-backdrop')).toHaveCount(0) + await expect(page.locator('.ew-brand-mark')).toHaveText('e') // lettermark fallback + await page.goto('/#/characters/official-nora?v=1') + await page.reload() + await settle(page) + await expect(page.locator('.ew-character-stage .ew-backdrop')).toHaveCount(0) + await shot(page, 'art-missing-character') + writeJSON('generated-art-missing', report) +}) diff --git a/everwith-claude-frontend/e2e/core.spec.ts b/everwith-claude-frontend/e2e/core.spec.ts new file mode 100644 index 00000000..2dcbcbf0 --- /dev/null +++ b/everwith-claude-frontend/e2e/core.spec.ts @@ -0,0 +1,112 @@ +/** + * Core journeys on the mock adapter: discover states, add-friend with sign-in in the + * middle, private chat (send / stop / retry / unknown result), draft persistence, and + * “play together” from a friend. Scenario switches are the documented mock scenarios. + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {shot, signUp, writeJSON} from './helpers' + +const setScenarios = (page: Page, on: Record) => page.evaluate(v => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify(v)), on) +const composer = (page: Page) => page.locator('.ew-chat-main textarea') +const playerBubbles = (page: Page, text: string) => page.locator('.ew-msg.is-player:not(.is-provisional) .ew-msg-bubble', {hasText: text}) + +test('discover: first visit, tabs, empty search vs load failure, deep link and Back', async ({page}) => { + await page.goto('/') + await expect(page).toHaveURL(/#\/discover$/) + await expect(page.locator('.ew-card').first()).toBeVisible() + await shot(page, 'discover-all-zh') + await page.getByRole('link', {name: '游戏', exact: true}).click() + await expect(page).toHaveURL(/tab=games/) + await shot(page, 'discover-games-zh') + await page.getByRole('searchbox').or(page.locator('.ew-search input')).first().fill('zzzz') + await expect(page.getByText('没有找到与“zzzz”相关的内容。')).toBeVisible() + await shot(page, 'discover-empty-search') + await setScenarios(page, {'discover.error': true}) + await page.goto('/#/discover?tab=stories') + await page.reload() + await expect(page.getByText('内容加载失败(不是“没有内容”)。')).toBeVisible() + await shot(page, 'discover-load-error') + await setScenarios(page, {}) + await page.reload() + await page.locator('.ew-card').first().click() + await expect(page).toHaveURL(/#\/stories\//) + await page.goBack() + await expect(page).toHaveURL(/tab=stories/) +}) + +test('add friend: naming → sign-in in the middle → same name restored → confirm → private chat', async ({page}) => { + await page.goto('/#/characters/official-lena?v=1') + await page.getByRole('button', {name: '加为好友'}).first().click() + const name = page.getByLabel('好友名字') + await name.fill('雨夜的 Lena') + await page.getByRole('button', {name: '确认加为好友'}).click() + await signUp(page, 'e2e-core@example.com') + await expect(page.getByLabel('好友名字')).toHaveValue('雨夜的 Lena') // restored, not auto-created + await shot(page, 'friend-naming-after-signin') + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page).toHaveURL(/#\/friends\/fr_/) + await expect(composer(page)).toBeVisible() + + // Send with Enter, receive a committed reply. + await composer(page).fill('你好,今天下雨了') + await composer(page).press('Enter') + await expect(playerBubbles(page, '你好,今天下雨了')).toHaveCount(1, {timeout: 20_000}) + await expect(page.locator('.ew-msg.is-character:not(.is-provisional)').last()).toBeVisible() + await page.waitForTimeout(1500) + await shot(page, 'chat-after-reply') + + // Draft survives a reload (per account/friend/epoch/conversation). + await composer(page).fill('还没发出的草稿') + await page.waitForTimeout(400) + await page.reload() + await expect(composer(page)).toHaveValue('还没发出的草稿') + + // Stop: the partial reply never enters history; the text comes back. + await composer(page).fill('请讲一个很长的故事') + await composer(page).press('Enter') + await page.locator('.ew-send.is-stop').click() + await expect(page.getByText('已停止。半段回复没有进入记录,原输入已放回输入框。')).toBeVisible({timeout: 15_000}) + await expect(playerBubbles(page, '请讲一个很长的故事')).toHaveCount(0) + await shot(page, 'chat-stopped') + + // Retryable failure through the Mock panel UI → explicit Retry of the same operation. + await page.getByTestId('mock-panel-open').click() + await page.getByLabel(/下一次私聊:生成中途可重试失败/).check() + await shot(page, 'mock-panel') + await page.keyboard.press('Escape') + await composer(page).fill('第二句话') + await composer(page).press('Enter') + await expect(page.getByText('这次生成中断了。原输入已保留,可以明确“重试”同一条(不会重复发送)。')).toBeVisible({timeout: 20_000}) + await shot(page, 'chat-retryable') + await page.getByRole('button', {name: '重试同一条'}).click() + await expect(playerBubbles(page, '第二句话')).toHaveCount(1, {timeout: 20_000}) + + // Unknown result: look up the original key first; never a second turn. + await setScenarios(page, {'chat.unknown_result': true}) + await page.reload() + await composer(page).fill('结果未知的一句') + await composer(page).press('Enter') + await expect(page.getByText('尚未确认这条消息是否被接收。请先“查询原请求”,不要重新发送。')).toBeVisible({timeout: 15_000}) + await shot(page, 'chat-unknown-result') + await page.getByRole('button', {name: '查询原请求'}).click() + const resend = page.getByRole('button', {name: '用同一请求重新发送'}) + if (await resend.isVisible({timeout: 3000}).catch(() => false)) await resend.click() + await expect(playerBubbles(page, '结果未知的一句')).toHaveCount(1, {timeout: 20_000}) + writeJSON('chat-core', {ok: true, checked: ['enter-send', 'draft-reload', 'stop', 'retry-same-op', 'unknown-lookup']}) + + // Play together → game prepare keeps the friend in seat 1 → explicit Start. + if (!(await page.getByRole('button', {name: '一起玩'}).isVisible())) await page.getByRole('button', {name: '工具'}).click() + await page.getByRole('button', {name: '一起玩'}).click() + await page.getByRole('button', {name: /Splendor/}).click() + await expect(page).toHaveURL(/#\/games\/gems\/prepare\?with=/) + await expect(page.locator('.ew-seat-row').nth(1)).toContainText('雨夜的 Lena') + await shot(page, 'play-together-prepare') + await page.locator('.ew-start-btn').click() + await expect(page).toHaveURL(/#\/play\/game\//) + await expect(page.locator('.ew-seat[data-seat="1"]')).toContainText('雨夜的 Lena') + await shot(page, 'play-together-table') + await page.getByRole('link', {name: /去私聊 · 雨夜的 Lena/}).click() + await expect(page).toHaveURL(/#\/friends\/fr_/) + await expect(composer(page)).toBeVisible() +}) diff --git a/everwith-claude-frontend/e2e/edge.spec.ts b/everwith-claude-frontend/e2e/edge.spec.ts new file mode 100644 index 00000000..bdc72ec4 --- /dev/null +++ b/everwith-claude-frontend/e2e/edge.spec.ts @@ -0,0 +1,285 @@ +/** + * Edge cases for A05 / A06 / A07 / A11 / A16 on the mock adapter: + * double-click send, Stop losing to a commit, late frames from a retired run, a stale head, + * a friend reset elsewhere, leaving mid-stream, expired sign-in; reading position / draft / + * selection across side panel, profile dialog and reload; game start receipt lost, a missing + * game, and a failed table-chat line. + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {shot, signUp, writeJSON} from './helpers' + +const composer = (page: Page) => page.locator('.ew-chat-main textarea') +const committed = (page: Page, text: string) => page.locator('.ew-msg.is-player:not(.is-provisional) .ew-msg-bubble', {hasText: text}) +const idle = (page: Page) => expect(page.locator('.ew-chat-main .ew-send.is-stop')).toHaveCount(0, {timeout: 20_000}) +async function scenario(page: Page, on: Record) { + await page.evaluate(v => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify(v)), on) + await page.reload() + await expect(composer(page)).toBeVisible() +} +async function befriend(page: Page, id: string, email: string) { + await page.goto(`/#/characters/${id}?v=1`) + await page.getByRole('button', {name: '加为好友'}).first().click() + await page.getByRole('button', {name: '确认加为好友'}).click() + await signUp(page, email) + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page).toHaveURL(/#\/friends\/fr_/) + await expect(composer(page)).toBeVisible() +} +async function say(page: Page, text: string) { + await composer(page).fill(text) + await composer(page).press('Enter') + await expect(committed(page, text)).toHaveCount(1, {timeout: 20_000}) + await idle(page) +} +const dbGames = (page: Page) => page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('everwith.mock.db.v1') ?? '{}').games ?? {}).length) + +test('chat races: double-click once, Stop that loses to the commit, late old-run frames, stale head, reset elsewhere, leaving mid-stream, expired sign-in', async ({page}) => { + test.setTimeout(180_000) + const report: Record = {} + await befriend(page, 'official-arthur', 'e2e-edge@example.com') + const chatUrl = page.url() + + // A05 double-click: one operation; the second click does not hit the Stop that replaced Send. + await composer(page).fill('只发一次') + await page.locator('.ew-chat-main .ew-send').dblclick() + await expect(committed(page, '只发一次')).toHaveCount(1, {timeout: 20_000}) + await idle(page) + await page.waitForTimeout(800) + expect(await committed(page, '只发一次').count()).toBe(1) + await expect(page.getByText('已停止。半段回复没有进入记录')).toHaveCount(0) + report.doubleClick = {committedTurns: 1, stopped: false} + + // A18 Cancel → neutral: a real Stop drops the provisional reply and the face returns to neutral. + await composer(page).fill('给我讲个开心的故事吧!') + await composer(page).press('Enter') + await expect(page.locator('.ew-chat-main .ew-msg.is-character.is-provisional').last()).toBeVisible({timeout: 10_000}) + await page.locator('.ew-chat-main .ew-send.is-stop').click() + await expect(page.getByText('已停止。半段回复没有进入记录,原输入已放回输入框。')).toBeVisible({timeout: 15_000}) + await expect(page.locator('.ew-chat-stage-meta .ew-chip').first()).toHaveText('平静') + await page.waitForTimeout(600) + report.cancelNeutral = {chip: '平静', runtimeExpressions: await page.evaluate(() => (window as unknown as {__everwithLive2D?: {snapshot(): {runtimes: {expression: string | null}[]}}}).__everwithLive2D?.snapshot().runtimes.map(r => r.expression) ?? null)} + await shot(page, 'chat-stop-neutral') + + // A05 Stop vs commit: the commit wins and is reported as such. + await scenario(page, {'chat.commit_before_cancel': true}) + await composer(page).fill('停止和提交赛跑') + await composer(page).press('Enter') + await page.locator('.ew-chat-main .ew-send.is-stop').click() + await expect(page.getByText('这条回复在停止前已经完成,结果已保留(没有被停止)。')).toBeVisible({timeout: 15_000}) + await expect(committed(page, '停止和提交赛跑')).toHaveCount(1) + await shot(page, 'chat-commit-won') + report.commitBeforeCancel = 'commit_won notice, reply kept' + + // A06 late frames from the retired run are rejected (run_epoch / seq check). + await scenario(page, {'chat.late_old_stream': true}) + await composer(page).fill('旧帧晚到') + await composer(page).press('Enter') + await page.getByRole('button', {name: '重试同一条'}).click({timeout: 20_000}) + await expect(committed(page, '旧帧晚到')).toHaveCount(1, {timeout: 20_000}) + await expect(page.getByText(/已拒绝 \d+ 个旧 run 的晚到帧/)).toBeVisible() + await expect(page.getByText('(这是旧 run 的晚到片段,不应显示)')).toHaveCount(0) + await shot(page, 'chat-late-frames-rejected') + report.lateOldRun = await page.getByText(/已拒绝 \d+ 个旧 run 的晚到帧/).innerText() + + // A06 stale head (another window committed first): latest loaded, draft kept, nothing resent. + await scenario(page, {'chat.stale_head': true}) + await composer(page).fill('我以为我是最新的') + await composer(page).press('Enter') + await expect(page.getByText('会话在别处有了新内容。已读取最新版本,你的草稿保留在输入框,没有自动重发。')).toBeVisible({timeout: 15_000}) + await expect(page.getByText('(另一窗口发送的消息)')).toBeVisible() + await expect(composer(page)).toHaveValue('我以为我是最新的') + await page.waitForTimeout(1500) + await expect(committed(page, '我以为我是最新的')).toHaveCount(0) + await shot(page, 'chat-stale-head') + report.staleHead = {draftKept: true, autoResent: false} + + // A06 leaving mid-stream: the unmounted view writes nothing; coming back shows one committed turn. + await composer(page).fill('离开再回来') + await composer(page).press('Enter') + await expect(page.locator('.ew-chat-main .ew-send.is-stop')).toBeVisible() + await page.goto('/#/discover') + await page.waitForTimeout(4500) + await page.goto(chatUrl) + await expect(committed(page, '离开再回来')).toHaveCount(1, {timeout: 20_000}) + await idle(page) + await expect(composer(page)).toHaveValue('') // the sent text is not resurrected as an old draft + report.leaveMidStream = {committedTurns: await committed(page, '离开再回来').count()} + + // A05 expired sign-in on send: the text comes back, nothing is recorded. (Switched on in + // the mock panel, not by reloading — the reload's own requests would consume it first.) + await page.getByTestId('mock-panel-open').click() + await page.getByLabel(/下一次受保护请求:登录过期 401/).check() + await page.keyboard.press('Escape') + await composer(page).fill('登录过期时的一句') + await composer(page).press('Enter') + await expect(page.getByText('登录已过期。草稿已保留,重新登录后可以继续。')).toBeVisible({timeout: 15_000}) + await expect(composer(page)).toHaveValue('登录过期时的一句') + await expect(committed(page, '登录过期时的一句')).toHaveCount(0) + await shot(page, 'chat-auth-expired') + + // A06 friend reset elsewhere: epoch changes, the old conversation turns read-only. + await scenario(page, {'chat.friend_reset_elsewhere': true}) + await composer(page).fill('重置后的消息') + await composer(page).press('Enter') + await expect(page.getByText('这位好友的状态在别处发生了变化(例如重置)。旧会话已变为只读,请重新打开。')).toBeVisible({timeout: 15_000}) + await shot(page, 'chat-epoch-changed') + await page.getByRole('button', {name: '重试', exact: true}).click() + await expect(composer(page)).toBeEnabled({timeout: 15_000}) + await expect(committed(page, '只发一次')).toHaveCount(0) // new epoch → fresh conversation + if (!(await page.getByText('较早的会话').isVisible())) await page.getByRole('button', {name: '工具'}).click() + await expect(page.getByRole('button', {name: /epoch 1/})).toBeVisible() + await page.getByRole('button', {name: /epoch 1/}).click() + await expect(page.locator('dialog[open]')).toContainText('只发一次') + await shot(page, 'chat-old-epoch-read-only') + await page.keyboard.press('Escape') + report.epochChanged = {oldConversationReadOnly: true} + writeJSON('chat-races', report) +}) + +test('reading old messages is not pulled down; reading position, draft and selection survive side panel, profile and reload', async ({browser}) => { + test.setTimeout(180_000) + const ctx = await browser.newContext({viewport: {width: 1280, height: 640}, locale: 'zh-CN'}) + const page = await ctx.newPage() + await befriend(page, 'official-iris', 'e2e-scroll@example.com') + const box = page.locator('.ew-chat-main .ew-transcript') + for (let i = 1; i <= 8; i++) { + await say(page, `第 ${i} 句`) + if (await box.evaluate(e => e.scrollHeight - e.clientHeight > 600)) break + } + // A reply is streaming; the reader scrolls up to old messages meanwhile. + await composer(page).fill('新的一句') + await composer(page).press('Enter') + await expect(page.locator('.ew-chat-main .ew-send.is-stop')).toBeVisible() + await box.evaluate(e => { e.scrollTop = 0; e.dispatchEvent(new Event('scroll')) }) + const samples: number[] = [] + for (let i = 0; i < 24; i++) { + samples.push(await box.evaluate(e => e.scrollTop)) + if (await committed(page, '新的一句').count() && !(await page.locator('.ew-chat-main .ew-send.is-stop').count())) break + await page.waitForTimeout(250) + } + await expect(committed(page, '新的一句')).toHaveCount(1, {timeout: 20_000}) + samples.push(await box.evaluate(e => e.scrollTop)) + expect(Math.max(...samples)).toBeLessThanOrEqual(2) + await expect(page.locator('.ew-new-pill')).toBeVisible() + await shot(page, 'chat-reading-old-new-pill') + + // Read a little further down (second turn aligned to the top), then keep a draft with a selection. + await box.evaluate(e => { + const turn = e.querySelectorAll('.ew-turn')[1] + e.scrollTop += turn.getBoundingClientRect().top - e.getBoundingClientRect().top + e.dispatchEvent(new Event('scroll')) + }) + await page.waitForTimeout(200) + const anchor = () => box.evaluate(e => { + const items = Array.from(e.querySelectorAll('[data-anchor]')) + const first = items.find(el => el.offsetTop + el.offsetHeight > e.scrollTop) + return first?.dataset.anchor ?? null + }) + const a0 = await anchor() + await composer(page).fill('草稿与选区') + await composer(page).press('End') + await composer(page).press('Shift+ArrowLeft') + await composer(page).press('Shift+ArrowLeft') + const sel = () => composer(page).evaluate(e => [(e as HTMLTextAreaElement).selectionStart, (e as HTMLTextAreaElement).selectionEnd]) + expect(await sel()).toEqual([3, 5]) + // Side panel open/close changes the transcript width: same anchor. + await page.getByRole('button', {name: '工具'}).click() + await page.waitForTimeout(300) + const a1 = await anchor() + await page.getByRole('button', {name: '资料'}).click() + await expect(page.locator('dialog[open]')).toBeVisible() + await page.keyboard.press('Escape') + await page.getByRole('button', {name: '工具'}).click() + await page.waitForTimeout(300) + const a2 = await anchor() + await expect(composer(page)).toHaveValue('草稿与选区') + await page.waitForTimeout(300) + await page.reload() + await expect(composer(page)).toHaveValue('草稿与选区') + await page.waitForTimeout(400) + const a3 = await anchor() + const selAfter = await sel() + const atEnd = await box.evaluate(e => e.scrollHeight - e.scrollTop - e.clientHeight < 40) + await shot(page, 'chat-reading-position-after-reload') + writeJSON('chat-reading-position', {scrollTopWhileStreaming: samples, anchors: {beforePanels: a0, panelOpen: a1, afterProfileAndClose: a2, afterReload: a3}, selectionAfterReload: selAfter, atEndAfterReload: atEnd}) + expect([a1, a2, a3]).toEqual([a0, a0, a0]) + expect(selAfter).toEqual([3, 5]) + expect(atEnd).toBe(false) + await ctx.close() +}) + +test('games: a lost start receipt finds the one game; a missing game is not replaced; a failed table line leaves the board untouched', async ({page}) => { + await page.goto('/#/games/vegas/prepare') + await page.evaluate(() => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify({'game.unknown_start': true}))) + await page.reload() + const before = await dbGames(page) + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//, {timeout: 20_000}) + const after = await dbGames(page) + expect(after).toBe(before + 1) + const gameUrl = page.url() + + // A missing game: an error with Retry / Recent — no substitute table is created. + await page.goto('/#/play/game/game_does_not_exist') + await expect(page.getByText('找不到这项内容,可能已被删除或无权访问。')).toBeVisible() + await page.getByRole('button', {name: '重试', exact: true}).click() + await expect(page.getByText('找不到这项内容,可能已被删除或无权访问。')).toBeVisible() + expect(await dbGames(page)).toBe(after) + await shot(page, 'game-missing-no-substitute') + + // Table chat line fails: the committed board and its revision do not move. + await page.goto(gameUrl) + await expect(page.getByTestId('table-status')).toBeVisible() + const pause = page.getByRole('button', {name: '暂停 AI'}) + if (await pause.isVisible()) await pause.click() + await page.waitForTimeout(1200) + const rev0 = Number(await page.locator('.ew-table-shell').getAttribute('data-revision')) + await page.evaluate(() => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify({'game.text_failed': true}))) + await page.reload() + await expect(page.getByTestId('table-status')).toBeVisible() + const pause2 = page.getByRole('button', {name: '暂停 AI'}) + if (await pause2.isVisible()) await pause2.click() + await page.waitForTimeout(1200) + const rev1 = Number(await page.locator('.ew-table-shell').getAttribute('data-revision')) + const box = page.locator('.ew-table-chat textarea') + await box.fill('这把你会怎么下?') + await box.press('Enter') + await expect(page.getByText('这次没能生成台词;牌局已提交的内容不受影响。')).toBeVisible({timeout: 10_000}) + const rev2 = Number(await page.locator('.ew-table-shell').getAttribute('data-revision')) + await shot(page, 'game-table-line-failed') + writeJSON('game-edge', {gamesBefore: before, gamesAfterLostReceipt: after, missingGameCreatedNothing: true, revisionBeforeChat: rev1, revisionAfterFailedLine: rev2, rev0}) + expect(rev2).toBe(rev1) +}) + +test('table stays operable: fullscreen refused, Esc twice after a dialog, immersive on', async ({page}) => { + await page.goto('/#/games/vegas/prepare') + await page.getByRole('button', {name: '2 人', exact: true}).click() + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + await expect(page.getByTestId('table-status')).toBeVisible() + const id = page.url() + // The browser refuses fullscreen: a readable notice, same layout, same table. + await page.evaluate(() => { HTMLElement.prototype.requestFullscreen = function () { return Promise.reject(new DOMException('denied', 'NotAllowedError')) } }) + await page.getByRole('button', {name: '全屏', exact: true}).click() + await expect(page.getByText('浏览器拒绝了全屏,已保留当前布局。')).toBeVisible() + expect(await page.evaluate(() => !!document.fullscreenElement)).toBe(false) + // A modal, then Esc twice: one layer per Esc, focus back on the trigger, nothing else closes. + const rules = page.getByRole('button', {name: '规则', exact: true}) + await rules.click() + await expect(page.locator('dialog[open]')).toBeVisible() + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await expect(page.locator('dialog[open]')).toHaveCount(0) + await expect(rules).toBeFocused() + await page.getByRole('button', {name: '沉浸', exact: true}).click() + await page.keyboard.press('Escape') + // The board still takes a move on the same session. + await page.waitForFunction(() => !!document.querySelector('[data-testid="royale-roll"]:not([disabled])'), null, {timeout: 30_000}) + const rev = Number(await page.locator('.ew-table-shell').getAttribute('data-revision')) + await page.getByTestId('royale-roll').click() + await page.waitForFunction(r => Number(document.querySelector('.ew-table-shell')?.dataset.revision) > r, rev, {timeout: 10_000}) + expect(page.url()).toBe(id) + await shot(page, 'game-operable-after-refused-fullscreen') + writeJSON('game-fullscreen-refused', {sameSession: true, revisionBefore: rev, moved: true}) +}) diff --git a/everwith-claude-frontend/e2e/extra.spec.ts b/everwith-claude-frontend/e2e/extra.spec.ts new file mode 100644 index 00000000..42780c1b --- /dev/null +++ b/everwith-claude-frontend/e2e/extra.spec.ts @@ -0,0 +1,91 @@ +/** + * Smaller business rules: sign-in cancel keeps position, table-mate → friend without a + * restart (F7), abandon → read-only unfinished, Recent continues the exact run, legacy + * Splendor v1 save keeps its rules, asset/model failure degrades, returning users land + * on Friends, Shift+Tab. + */ +import {expect, test} from '@playwright/test' +import {settle, shot, signUp, tableSnap, writeJSON} from './helpers' + +test('table-mate becomes a friend in place (no restart / no extra seat); sign-in cancel keeps position; abandon is read-only', async ({page}) => { + await page.goto('/#/games/cabo/prepare') + await page.getByRole('button', {name: '2 人', exact: true}).click() + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + const before = await tableSnap(page) + const seatName = (await page.locator('.ew-seat[data-seat="1"] .ew-seat-text strong').innerText()).trim() + await page.getByRole('button', {name: `加为好友 · ${seatName}`}).click() + await page.getByRole('button', {name: '确认加为好友'}).click() + // Cancel sign-in: back on the same table, naming dialog still holds the name. + await page.locator('dialog[open]').getByRole('button', {name: '取消'}).click() + await expect(page.getByLabel('好友名字')).toHaveValue(seatName) + expect((await tableSnap(page)).url).toBe(before.url) + await page.getByRole('button', {name: '确认加为好友'}).click() + await signUp(page, 'e2e-extra@example.com') + await page.getByLabel('好友名字').fill(`${seatName}(牌友)`) + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page.locator('.ew-seat[data-seat="1"]')).toContainText('好友') + const after = await tableSnap(page) + expect(after.url).toBe(before.url) + expect(await page.locator('.ew-seat').count()).toBe(2) + await shot(page, 'game-tablemate-befriended') + // Explicit abandon → unfinished, read-only, no invented winner. + await page.getByRole('button', {name: '放弃本局'}).click() + await page.locator('dialog[open] .ew-btn.danger').click() + await expect(page.getByText('已放弃 · 未完成(只读)').first()).toBeVisible() + await shot(page, 'game-abandoned') + await page.goto('/#/recent') + await expect(page.getByTestId('record').first()).toContainText('已放弃(未完成)') + writeJSON('tablemate-friend-abandon', {sameSession: after.url === before.url, seats: 2}) +}) + +test('Recent continues the exact run; returning users land on Friends; legacy Splendor v1 keeps its rules', async ({page}) => { + await page.goto('/#/stories/story-last-train/prepare?v=1') + await page.getByTestId('story-start').click() + await expect(page.getByTestId('story-play')).toBeVisible() + const runUrl = page.url() + await page.goto('/#/recent') + await page.getByTestId('record-open').first().click() + await expect(page).toHaveURL(runUrl) + // Legacy save fixture through the mock panel. + await page.getByTestId('mock-panel-open').click() + await page.getByRole('button', {name: /v1/}).click() + await page.waitForURL(/#\/play\/game\//) + await expect(page.getByText('旧版 v1 存档(保持原规则)').first()).toBeVisible() + await expect(page.locator('.sp-board')).toBeVisible() + const take = await page.getByTestId('splendor-legal').getAttribute('data-take') + await shot(page, 'splendor-legacy-v1') + writeJSON('splendor-legacy-v1', {legalTakesOffered: take}) + // Make a friend, then the empty hash should land on Friends. + await page.goto('/#/characters/official-iris?v=1') + await page.getByRole('button', {name: '加为好友'}).first().click() + await page.getByRole('button', {name: '确认加为好友'}).click() + await signUp(page, 'e2e-root@example.com') + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page).toHaveURL(/#\/friends\/fr_/) + await page.goto('/#/') + await expect(page).toHaveURL(/#\/friends$/) +}) + +test('model or image failures degrade to the same person / neutral placeholders; chat still works', async ({page}) => { + await page.goto('/#/discover') + await page.evaluate(() => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify({'live2d.model_missing': true, 'assets.missing': true}))) + await page.goto('/#/characters/official-marcus?v=1') + await page.reload() + await expect(page.getByText('动态形象不可用,显示同一角色的静态图')).toBeVisible() + await settle(page) + await shot(page, 'degraded-model-and-images') + await page.goto('/#/discover') + await expect(page.locator('.ew-card').first()).toBeVisible() + await expect(page.locator('.ew-portrait.is-placeholder').first()).toBeVisible() + await shot(page, 'degraded-discover-placeholders') +}) + +test('Shift+Tab walks focus backwards', async ({page}) => { + await page.goto('/#/account') + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab') + const third = await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 80)) + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + expect(await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 80))).toBe(third) +}) diff --git a/everwith-claude-frontend/e2e/games.spec.ts b/everwith-claude-frontend/e2e/games.spec.ts new file mode 100644 index 00000000..aaea9340 --- /dev/null +++ b/everwith-claude-frontend/e2e/games.spec.ts @@ -0,0 +1,266 @@ +/** + * @long Three complete games through the NEW UI at normal product speed, recorded on video. + * The player seat is driven only by clicking visible controls; NPC seats are the mock's + * fixed-strategy “rule-simulated companions”. Each run also checks pause / sidebar / + * immersive / fullscreen / reload continuity on the same session id. + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {renameSync} from 'node:fs' +import {act, evidencePath, gameRecord, shot, tableSnap, writeJSON} from './helpers' + +test.describe.configure({mode: 'serial'}) +// actionTimeout: a control that never becomes clickable fails the run with a precise line instead of hanging. +test.use({video: {mode: 'on', size: {width: 1280, height: 800}}, viewport: {width: 1280, height: 800}, actionTimeout: 20_000}) + +type Log = {checks: Record; myActions: number; shots: string[]} + +/** Resume AI seats. The button keeps its id until the hold(false) request returns, so never block on it: + * click if it is still there, then wait until the toolbar shows “暂停 AI” again. */ +async function resumeAI(page: Page) { + await page.getByTestId('continue-ai').click({timeout: 3000}).catch(() => undefined) + await expect(page.getByTestId('continue-ai')).toHaveCount(0, {timeout: 8000}).catch(() => undefined) +} + +async function startGame(page: Page, gameId: string, players: number) { + await page.goto(`/#/games/${gameId}/prepare`) + await page.getByRole('button', {name: `${players} 人`, exact: true}).click() + await expect(page.locator('.ew-seat-row')).toHaveCount(players) + await shot(page, `game-${gameId}-prepare`) + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + await expect(page.getByTestId('table-status')).toBeVisible() + return decodeURIComponent(page.url().split('/play/game/')[1]) +} + +/** Same session, same mounted board: pause, sidebar, immersive, fullscreen, reload. */ +async function continuityChecks(page: Page, game: string, log: Log) { + const id = (await tableSnap(page)).url + // Pause AI (if running) and confirm nothing advances. + const pause = page.getByRole('button', {name: '暂停 AI'}) + if (await pause.isVisible()) await pause.click() + const r0 = (await tableSnap(page)).rev + await page.waitForTimeout(2500) + const r1 = (await tableSnap(page)).rev + log.checks.pauseHolds = {before: r0, after2500ms: r1, ok: r1 === r0} + await page.evaluate(() => { (document.querySelector('.gb-board') as unknown as {__e2e?: string}).__e2e = 'mounted-once' }) + const mark = () => page.evaluate(() => (document.querySelector('.gb-board') as unknown as {__e2e?: string} | null)?.__e2e ?? null) + await page.getByRole('button', {name: '人物与桌聊'}).click() + await shot(page, `game-${game}-rail-closed`) + await page.getByRole('button', {name: '人物与桌聊'}).click() + await page.getByRole('button', {name: '沉浸', exact: true}).click() + await shot(page, `game-${game}-immersive`) + await page.getByRole('button', {name: '退出沉浸'}).click() + await page.getByRole('button', {name: '全屏', exact: true}).click() + await page.waitForTimeout(600) + const fs = await page.evaluate(() => !!document.fullscreenElement) + if (fs) { await shot(page, `game-${game}-fullscreen`); await page.evaluate(() => document.exitFullscreen()); await page.waitForTimeout(400) } + const s2 = await tableSnap(page) + log.checks.sameBoardAfterToggles = {mark: await mark(), sameUrl: s2.url === id, rev: s2.rev, revUnchangedWhilePaused: s2.rev === r0, fullscreenEntered: fs} + expect(await mark()).toBe('mounted-once') + expect(s2.url).toBe(id) + // Resume, then leave by reloading: the in-flight step finishes, later steps pause. + if (await page.getByTestId('continue-ai').isVisible()) await resumeAI(page) + await page.waitForTimeout(900) + const before = await tableSnap(page) + await page.reload() + await expect(page.getByTestId('table-status')).toBeVisible() + const after = await tableSnap(page) + const banner = await page.getByText('离开期间后续行动已暂停').isVisible().catch(() => false) + log.checks.reload = {urlBefore: before.url, urlAfter: after.url, revBefore: before.rev, revAfter: after.rev, leftPausedBanner: banner} + expect(after.url).toBe(before.url) + expect(after.rev).toBeGreaterThanOrEqual(before.rev) + if (banner) { await shot(page, `game-${game}-reload-paused`); await resumeAI(page) } +} + +async function finish(page: Page, game: string, id: string, log: Log, t0: number) { + await expect(page.getByTestId('result')).toBeVisible() + await page.waitForTimeout(1200) + await shot(page, `game-${game}-result`) + const rec = await gameRecord(page, id) + // Locked result can still be chatted about; the score does not move. + const box = page.locator('.ew-table-chat textarea') + await box.fill('这一局打得不错') + await box.press('Enter') + await expect(page.locator('.ew-chat-line.is-me').last()).toContainText('这一局打得不错') + const rec2 = await gameRecord(page, id) + log.checks.chatAfterEndKeepsScore = JSON.stringify(rec2?.settlement?.scores) === JSON.stringify(rec?.settlement?.scores) && rec2?.revision === rec?.revision + await shot(page, `game-${game}-result-chat`) + writeJSON(`game-${game}`, {game, session: id, profile: rec?.profile, syntheticSeed: rec?.seed, finalRevision: rec?.revision, lifecycle: rec?.lifecycle, + seats: rec?.seats, settlement: rec?.settlement, myActions: log.myActions, wallClockSeconds: Math.round((Date.now() - t0) / 1000), checks: log.checks, + note: 'Normal product pacing (820 ms per NPC step). NPC seats = fixed-strategy mock; JEV not connected.'}) + expect(rec?.lifecycle).toBe('ended') +} + +async function saveVideo(page: Page, name: string) { + const video = page.video() + await page.close() + if (video) { const p = await video.path(); renameSync(p, evidencePath('videos', `${name}.webm`)) } +} + +test('Royale · 4 players · three rounds to the final settlement @long', async ({page}) => { + test.setTimeout(20 * 60_000) + const id = await startGame(page, 'vegas', 4) + const log: Log = {checks: {}, myActions: 0, shots: []} + const t0 = Date.now() + let passed = false, chose = 0, checked = false + const rounds = new Set() + while (Date.now() - t0 < 18 * 60_000) { + const s = await tableSnap(page) + if (s.result) break + const b = s.buttons + if (!checked && s.rev > 25 && b.includes('royale-roll')) { checked = true; await continuityChecks(page, 'royale', log); continue } + if (b.includes('royale-roll')) { await act(page, 'royale-roll'); log.myActions++; if (log.myActions === 1) { await page.waitForTimeout(300); await shot(page, 'game-royale-rolling') } continue } + const face = b.find(x => x.startsWith('royale-face-')) + if (face) { + chose++ + if (!passed && chose === 2 && b.includes('royale-pass')) { passed = true; await page.getByTestId('royale-pass').click(); await shot(page, 'game-royale-pass-confirm'); await act(page, 'royale-confirm'); log.myActions++; continue } + await page.getByTestId(face).click() + if (chose === 1) await shot(page, 'game-royale-select-confirm') + await act(page, 'royale-confirm'); log.myActions++; continue + } + if (b.includes('royale-payout')) { await act(page, 'royale-payout'); log.myActions++; continue } + if (b.includes('royale-next')) { const r = await page.locator('.rb-board').getAttribute('data-round'); if (r && !rounds.has(r)) { rounds.add(r); await shot(page, `game-royale-round${r}-end`) } await act(page, 'royale-next'); log.myActions++; continue } + if (b.includes('continue-ai')) { await resumeAI(page); continue } + await page.waitForTimeout(250) + } + log.checks.passUsed = passed + await finish(page, 'royale', id, log, t0) + // Rematch keeps the same people and creates a NEW session; the old result stays. + await page.getByRole('button', {name: '原桌再来一局'}).first().click() + await page.waitForURL(u => !u.toString().includes(id)) + const fresh = decodeURIComponent(page.url().split('/play/game/')[1]) + const a = await gameRecord(page, id), b = await gameRecord(page, fresh) + writeJSON('game-royale-rematch', {old: id, oldLifecycle: a?.lifecycle, oldScores: a?.settlement?.scores, fresh, freshSeats: b?.seats, sameNames: JSON.stringify(a?.seats.map(s => s.name)) === JSON.stringify(b?.seats.map(s => s.name))}) + expect(a?.lifecycle).toBe('ended') + expect(b?.revision).toBe(1) + await saveVideo(page, 'royale-full-game') +}) + +test('Splendor v2 · 4 players · 15 points and the round completed @long', async ({page}) => { + test.setTimeout(20 * 60_000) + const id = await startGame(page, 'gems', 4) + const log: Log = {checks: {}, myActions: 0, shots: []} + const t0 = Date.now() + let turns = 0, reserved = false, checked = false + const seen = new Set() + while (Date.now() - t0 < 18 * 60_000) { + const s = await tableSnap(page) + if (s.result) break + if (s.current !== '0' || !s.buttons.some(x => x.startsWith('splendor-'))) { + if (s.buttons.includes('continue-ai')) { await resumeAI(page); continue } + if (!checked && s.rev > 30) { checked = true; await continuityChecks(page, 'splendor', log); continue } + await page.waitForTimeout(250); continue + } + if (s.phase === 'return') { + if (!seen.has('return')) { seen.add('return'); await shot(page, 'game-splendor-return-before') } + // Plan exactly K returns from the counts shown on my own gem buttons. + const prompt = await page.locator('.gb-prompt').innerText() + let need = Number(prompt.match(/归还 (\d+) 枚|return (\d+)/)?.slice(1).find(Boolean) ?? 0) + const options = await page.locator('[data-testid^="splendor-return-"]').evaluateAll(els => els.map(e => ({id: (e as HTMLElement).dataset.testid!, count: Number(e.querySelector('b')?.textContent ?? 0)}))) + for (const o of options) for (let k = 0; k < o.count && need > 0; k++, need--) await page.getByTestId(o.id).click() + await shot(page, 'game-splendor-return-selected') + await page.getByTestId('splendor-confirm').click(); log.myActions++ + await page.waitForFunction(() => document.querySelector('.gb-board')?.dataset.phase !== 'return' || document.querySelector('.gb-board')?.dataset.current !== '0', null, {timeout: 15_000}) + continue + } + if (s.phase === 'noble') { + await page.locator('[data-testid^="splendor-noble-"]').first().click() + await shot(page, 'game-splendor-noble-choice') + await act(page, 'splendor-confirm'); log.myActions++; continue + } + turns++ + const legal = await page.getByTestId('splendor-legal').evaluate(e => ({buy: (e.dataset.buy ?? '').split(' ').filter(Boolean), take: (e.dataset.take ?? '').split(' ').filter(Boolean), reserve: (e.dataset.reserve ?? '').split(' ').filter(Boolean), blind: (e.dataset.blind ?? '').split(' ').filter(Boolean)})) + if (legal.buy.length) { + await page.getByTestId(`splendor-card-${legal.buy[0]}`).click() + if (!seen.has('buy')) { seen.add('buy'); await shot(page, 'game-splendor-buy-preview') } + await act(page, 'splendor-buy'); log.myActions++; continue + } + if (!reserved && turns === 3 && legal.reserve.length) { + reserved = true + await page.getByTestId(`splendor-card-${legal.reserve[0]}`).click() + await shot(page, 'game-splendor-reserve-preview') + await act(page, 'splendor-reserve'); log.myActions++; continue + } + if (legal.take.length) { + const best = legal.take.reduce((a, b) => b.split('+').length > a.split('+').length ? b : a) + for (const c of best.split('+')) await page.getByTestId(`splendor-bank-${c}`).click() + if (!seen.has('take')) { seen.add('take'); await shot(page, 'game-splendor-take-selected') } + await act(page, 'splendor-confirm'); log.myActions++; continue + } + if (legal.blind.length) { await page.getByTestId(`splendor-deck-${legal.blind[0]}`).click(); await act(page, 'splendor-confirm'); log.myActions++; continue } + throw new Error('no legal action offered to seat 0') + } + log.checks.reserveUsed = reserved + log.checks.returnPhaseSeen = seen.has('return') + await finish(page, 'splendor', id, log, t0) + await saveVideo(page, 'splendor-full-game') +}) + +test('CABO · 4 players · cumulative end above 100 @long', async ({page}) => { + test.setTimeout(25 * 60_000) + const id = await startGame(page, 'cabo', 4) + const log: Log = {checks: {}, myActions: 0, shots: []} + const t0 = Date.now() + let called = false, checked = false + const seen = new Set() + const cabo = () => page.getByTestId('cabo-state').evaluate(e => ({phase: e.dataset.phase!, current: e.dataset.current!, drawn: e.dataset.drawn ? Number(e.dataset.drawn) : null, ability: e.dataset.ability || null, totals: e.dataset.totals!, caller: e.dataset.caller})) + while (Date.now() - t0 < 23 * 60_000) { + const s = await tableSnap(page) + if (s.result) break + const b = s.buttons + const c = await cabo() + const round = Number(await page.locator('.cb-board').getAttribute('data-round')) + if (process.env.E2E_DEBUG) console.log(`[cabo] r${s.rev} round ${round} phase ${c.phase} cur ${c.current} drawn ${c.drawn} ability ${c.ability} buttons ${b.join(',')}`) + if (b.includes('cabo-next-round')) { if (!seen.has(`end${round}`)) { seen.add(`end${round}`); await shot(page, `game-cabo-round${round}-scores`) } await act(page, 'cabo-next-round'); log.myActions++; continue } + if (!b.some(x => x.startsWith('cabo-'))) { + if (b.includes('continue-ai')) { await resumeAI(page); continue } + if (!checked && s.rev > 40) { checked = true; await continuityChecks(page, 'cabo', log); continue } + await page.waitForTimeout(250); continue + } + if (c.phase === 'setup') { + await page.getByTestId('cabo-own-0').click(); await page.getByTestId('cabo-own-1').click() + if (!seen.has('setup')) { seen.add('setup'); await shot(page, 'game-cabo-setup-select') } + await act(page, 'cabo-peek-confirm'); log.myActions++; continue + } + if (c.phase === 'reveal') { + if (!seen.has(`reveal${round}`)) { seen.add(`reveal${round}`); await shot(page, `game-cabo-private-reveal-r${round}`) } + await act(page, 'cabo-ack'); log.myActions++ + if (!seen.has('after-ack')) { seen.add('after-ack'); await shot(page, 'game-cabo-after-ack-known-badges') } + continue + } + if (c.phase === 'turn') { + if (!called && round >= 2 && c.caller === '' && b.includes('cabo-call')) { + called = true + await page.getByTestId('cabo-call').click(); await shot(page, 'game-cabo-call-confirm') + await act(page, 'cabo-call-confirm'); log.myActions++; continue + } + const top = await page.getByTestId('cabo-discard-top').getAttribute('aria-label', {timeout: 2000}).catch(() => '') + const topValue = Number((top ?? '').match(/[::]\s*(\d+)/)?.[1] ?? 99) + if (topValue <= 2 && b.includes('cabo-draw-discard')) { await act(page, 'cabo-draw-discard'); log.myActions++; continue } + await act(page, 'cabo-draw-deck'); log.myActions++; continue + } + if (c.phase === 'exchange') { await page.getByTestId('cabo-own-3').or(page.getByTestId('cabo-own-0')).first().click(); await act(page, 'cabo-exchange'); log.myActions++; continue } + if (c.phase === 'drawn') { + if (!seen.has('drawn')) { seen.add('drawn'); await shot(page, 'game-cabo-drawn') } + if (c.drawn !== null && c.drawn <= 4) { await page.locator('[data-testid^="cabo-own-"]').last().click(); await act(page, 'cabo-exchange'); log.myActions++; continue } + if (b.includes('cabo-discard-ability')) { await act(page, 'cabo-discard-ability'); log.myActions++; continue } + await act(page, 'cabo-discard'); log.myActions++; continue + } + if (c.phase === 'ability') { + if (!seen.has(`ability-${c.ability}`)) { seen.add(`ability-${c.ability}`) } + if (c.ability === 'peek') await page.locator('button[data-testid^="cabo-own-"]').first().click() + else if (c.ability === 'spy') await page.locator('button[data-testid^="cabo-seat"]').first().click() + else { await page.locator('button[data-testid^="cabo-own-"]').first().click(); await page.locator('button[data-testid^="cabo-seat"]').first().click() } + if (await page.getByTestId('cabo-ability-confirm').isEnabled({timeout: 3000}).catch(() => false)) { await shot(page, `game-cabo-ability-${c.ability}`); await act(page, 'cabo-ability-confirm') } else await act(page, 'cabo-ability-cancel') + log.myActions++; continue + } + await page.waitForTimeout(250) + } + log.checks.cabocalled = called + log.checks.abilitiesSeen = [...seen].filter(x => x.startsWith('ability-')) + await finish(page, 'cabo', id, log, t0) + const totals = (await gameRecord(page, id))?.settlement?.scores ?? [] + expect(Math.max(...totals)).toBeGreaterThan(100) + await saveVideo(page, 'cabo-full-game') +}) diff --git a/everwith-claude-frontend/e2e/helpers.ts b/everwith-claude-frontend/e2e/helpers.ts new file mode 100644 index 00000000..9a1aa13f --- /dev/null +++ b/everwith-claude-frontend/e2e/helpers.ts @@ -0,0 +1,60 @@ +import {expect} from '@playwright/test' +import type {Page} from '@playwright/test' +import {mkdirSync, writeFileSync} from 'node:fs' +import {dirname, resolve} from 'node:path' + +export const EVIDENCE = resolve(process.cwd(), 'evidence') +export function evidencePath(...parts: string[]) { const p = resolve(EVIDENCE, ...parts); mkdirSync(dirname(p), {recursive: true}); return p } +/** Wait until visible images and fonts are ready (bounded), so screenshots show real art. */ +export async function settle(page: Page, ms = 6000) { + await Promise.race([page.waitForLoadState('networkidle').catch(() => undefined), page.waitForTimeout(ms)]) + // Loading skeletons gone (the page shows content, not its loading state). + await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'), null, {timeout: ms}).catch(() => undefined) + // Visible images loaded AND decoded (lazy + decoding="async" can be "complete" before the first paint). + await page.waitForFunction(() => Array.from(document.images).filter(i => { const r = i.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.width > 0 }).every(i => i.complete), null, {timeout: ms}).catch(() => undefined) + await page.evaluate(async () => { + const visible = Array.from(document.images).filter(i => { const r = i.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.width > 0 }) + await Promise.race([Promise.all(visible.map(i => i.decode().catch(() => undefined))), new Promise(r => setTimeout(r, 3000))]) + await document.fonts.ready + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + }).catch(() => undefined) +} +export async function shot(page: Page, name: string, fullPage = false) { await settle(page, 4000); await page.screenshot({path: evidencePath('screenshots', `${name}.png`), fullPage}) } +export function writeJSON(name: string, data: unknown) { writeFileSync(evidencePath('reports', `${name}.json`), JSON.stringify(data, null, 2)) } + +/** Atomic read of the table state (one evaluate; avoids mixing two revisions). */ +export type TableSnap = {rev: number; lifecycle: string; phase: string; current: string; buttons: string[]; result: boolean; url: string} +export async function tableSnap(page: Page): Promise { + return page.evaluate(() => { + const shell = document.querySelector('.ew-table-shell') + const board = document.querySelector('.gb-board') + const buttons = [...document.querySelectorAll('button[data-testid]')].filter(b => !b.disabled && b.offsetParent !== null).map(b => b.dataset.testid!) + return {rev: Number(shell?.dataset.revision ?? -1), lifecycle: shell?.dataset.lifecycle ?? '', phase: board?.dataset.phase ?? '', current: board?.dataset.current ?? '', + buttons, result: !!document.querySelector('[data-testid="result"]'), url: location.hash} + }) +} +/** Click, then wait until the authoritative revision moves (or a short timeout). */ +export async function act(page: Page, testId: string) { + const before = (await tableSnap(page)).rev + await page.getByTestId(testId).first().click() + await page.waitForFunction(r => Number(document.querySelector('.ew-table-shell')?.dataset.revision ?? -1) !== r, before, {timeout: 10_000}).catch(() => undefined) +} +export async function signUp(page: Page, email: string, password = 'password123') { + const dialog = page.locator('dialog[open]') + await expect(dialog).toBeVisible() + await dialog.getByText(/没有账号?注册|No account\? Create one/).click() + await dialog.locator('input[type=email]').fill(email) + await dialog.locator('input[type=password]').fill(password) + await dialog.locator('button[type=submit]').click() + // The sign-in layer closes; the interrupted step (e.g. naming) may reopen as its own dialog. + await expect(page.locator('dialog[open] input[type=email]')).toHaveCount(0) +} +export type GameRecord = {seed: number; profile: string; revision: number; lifecycle: string; settlement: {scores: number[]; winners: number[]} | null; seats: {seat: number; kind: string; name: string}[]} +/** Reads the synthetic seed of a game from the local mock DB (evidence only). */ +export async function gameRecord(page: Page, id: string): Promise { + return page.evaluate(gid => { + const db = JSON.parse(localStorage.getItem('everwith.mock.db.v1') ?? '{}') + const g = db.games?.[gid] + return g ? {seed: g.seed, profile: g.profile, revision: g.revision, lifecycle: g.lifecycle, settlement: g.settlement, seats: g.seats.map((s: {seat: number; kind: string; name: string}) => ({seat: s.seat, kind: s.kind, name: s.name}))} : null + }, id) +} diff --git a/everwith-claude-frontend/e2e/layout-a11y.spec.ts b/everwith-claude-frontend/e2e/layout-a11y.spec.ts new file mode 100644 index 00000000..f5ec740b --- /dev/null +++ b/everwith-claude-frontend/e2e/layout-a11y.spec.ts @@ -0,0 +1,143 @@ +/** + * Desktop layout matrix (1024 / 1280 / 1440 / 1920 × 中文 / English), 200 % zoom + * (emulated as half-size CSS viewport at deviceScaleFactor 2), keyboard focus order, + * Esc + focus return, reduced motion, and a synthetic CDP IME composition. + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {evidencePath, settle, shot, writeJSON} from './helpers' + +const VIEWPORTS: [number, number][] = [[1024, 768], [1280, 800], [1440, 900], [1920, 1080]] +const overflow = (page: Page) => page.evaluate(() => { + const doc = document.documentElement, main = document.querySelector('.ew-main') + return {docOverflowX: doc.scrollWidth - window.innerWidth, mainOverflowX: main ? main.scrollWidth - main.clientWidth : 0} +}) +async function setLang(page: Page, lang: 'zh' | 'en') { + await page.goto('/#/discover') + await page.evaluate(l => localStorage.setItem('everwith.ui.lang', JSON.stringify(l)), lang) +} +async function startSplendor(page: Page) { + await page.goto('/#/games/gems/prepare') + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + await expect(page.locator('.sp-board')).toBeVisible() +} + +test('desktop matrix: four widths × two languages, no horizontal overflow @long', async ({browser}) => { + test.setTimeout(10 * 60_000) + const results: Record[] = [] + for (const [w, h] of VIEWPORTS) for (const lang of ['zh', 'en'] as const) { + const ctx = await browser.newContext({viewport: {width: w, height: h}, locale: lang === 'zh' ? 'zh-CN' : 'en-US'}) + const page = await ctx.newPage() + await setLang(page, lang) + const pages: [string, () => Promise][] = [ + ['discover', async () => { await page.goto('/#/discover'); await expect(page.locator('.ew-card').first()).toBeVisible() }], + ['character', async () => { await page.goto('/#/characters/official-nora?v=1'); await expect(page.locator('.ew-stage')).toBeVisible(); await page.waitForTimeout(1200) }], + ['story', async () => { await page.goto('/#/stories/story-promise-rain/prepare?v=1'); await page.getByTestId('story-start').click(); await expect(page.getByTestId('story-play')).toBeVisible(); await page.waitForTimeout(800) }], + ['splendor-table', async () => { await startSplendor(page); await page.waitForTimeout(800) }], + ['cabo-prepare', async () => { await page.goto('/#/games/cabo/prepare'); await expect(page.locator('.ew-start-btn')).toBeVisible() }], + ] + for (const [name, go] of pages) { + await go() + await settle(page) + const o = await overflow(page) + await page.screenshot({path: evidencePath('screenshots', 'matrix', `${name}-${w}x${h}-${lang}.png`)}) + results.push({page: name, viewport: `${w}x${h}`, lang, ...o}) + expect(o.docOverflowX, `${name} ${w} ${lang}`).toBeLessThanOrEqual(1) + } + await ctx.close() + } + writeJSON('layout-matrix', results) +}) + +test('200 % zoom (CSS 640×400 and 720×450 at 2×): content reflows, actions reachable', async ({browser}) => { + const results: Record[] = [] + for (const [w, h] of [[640, 400], [720, 450]] as [number, number][]) { + const ctx = await browser.newContext({viewport: {width: w, height: h}, deviceScaleFactor: 2, locale: 'zh-CN'}) + const page = await ctx.newPage() + await page.goto('/#/discover') + await expect(page.locator('.ew-card').first()).toBeVisible() + await settle(page) + await page.screenshot({path: evidencePath('screenshots', 'zoom200', `discover-${w}x${h}.png`)}) + results.push({page: 'discover', viewport: `${w}x${h}@2x`, ...(await overflow(page))}) + await startSplendor(page) + const bar = page.locator('.gb-actionbar') + await expect(bar).toBeVisible() + await settle(page) + await page.screenshot({path: evidencePath('screenshots', 'zoom200', `splendor-${w}x${h}.png`)}) + await page.locator('[data-testid^="splendor-bank-"]').first().scrollIntoViewIfNeeded() + await page.locator('[data-testid^="splendor-bank-"]').first().click() + await expect(page.getByTestId('splendor-cancel')).toBeVisible() + results.push({page: 'splendor-table', viewport: `${w}x${h}@2x`, ...(await overflow(page)), bankReachable: true}) + await ctx.close() + } + writeJSON('layout-zoom200', results) +}) + +test('keyboard: skip link, visible focus, Esc closes one dialog layer and returns focus', async ({page}) => { + await page.goto('/#/characters/official-arthur?v=1') + const add = page.getByRole('button', {name: '加为好友'}).first() + await expect(add).toBeVisible() // content loaded before walking the tab order + await page.keyboard.press('Tab') + await expect(page.locator('.ew-skip')).toBeFocused() + // Skip link → main content first. + await page.keyboard.press('Enter') + const afterSkip = await page.evaluate(() => document.activeElement?.id || document.activeElement?.className || '') + const order: string[] = [] + for (let i = 0; i < 16; i++) { await page.keyboard.press('Tab'); order.push(await page.evaluate(() => { const e = document.activeElement as HTMLElement; return `${e.tagName.toLowerCase()}:${(e.getAttribute('aria-label') ?? e.textContent ?? '').trim().slice(0, 24)}` })) } + expect(order.some(o => o.includes('加为好友')), order.join(' | ')).toBe(true) + await add.focus() + await page.keyboard.press('Enter') + await expect(page.locator('dialog[open]')).toBeVisible() + await expect(page.getByLabel('好友名字')).toBeFocused() + await shot(page, 'keyboard-dialog-focus') + await page.keyboard.press('Escape') + await expect(page.locator('dialog[open]')).toHaveCount(0) + await expect(add).toBeFocused() + // Game table: rules dialog via keyboard; Esc returns focus to the trigger. + await page.goto('/#/games/cabo/prepare') + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + const rules = page.getByRole('button', {name: '规则', exact: true}) + await rules.focus() + await page.keyboard.press('Enter') + await expect(page.locator('dialog[open]')).toBeVisible() + await page.keyboard.press('Escape') + await expect(rules).toBeFocused() + const focusRing = await rules.evaluate(e => getComputedStyle(e).boxShadow) + writeJSON('keyboard', {skipTarget: afterSkip, tabOrderAfterSkip: order, rulesFocusRing: focusRing}) + expect(focusRing).not.toBe('none') +}) + +test('reduced motion: OS preference collapses animations; the board stays operable', async ({browser}) => { + const ctx = await browser.newContext({reducedMotion: 'reduce', viewport: {width: 1280, height: 800}, locale: 'zh-CN'}) + const page = await ctx.newPage() + await page.goto('/#/games/vegas/prepare') + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + await page.getByTestId('royale-roll').click() + const dur = await page.locator('.rb-arena .rb-die').first().evaluate(e => getComputedStyle(e).animationDuration) + await expect(page.locator('[data-testid^="royale-face-"]').first()).toBeVisible() + await page.screenshot({path: evidencePath('screenshots', 'reduced-motion-royale.png')}) + writeJSON('reduced-motion', {animationDuration: dur}) + expect(parseFloat(dur)).toBeLessThan(0.01) + await ctx.close() +}) + +test('IME (synthetic, CDP composition): Enter during composition does not send', async ({page}) => { + await page.goto('/#/games/cabo/prepare') + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + const box = page.locator('.ew-table-chat textarea') + await box.click() + const cdp = await page.context().newCDPSession(page) + await cdp.send('Input.imeSetComposition', {selectionStart: 2, selectionEnd: 2, text: 'ni'}) + await page.keyboard.press('Enter') + const during = await page.locator('.ew-chat-line.is-me').count() + await cdp.send('Input.insertText', {text: '你好'}) + await page.waitForTimeout(100) + await page.keyboard.press('Enter') + await expect(page.locator('.ew-chat-line.is-me')).toHaveCount(1) + writeJSON('ime-synthetic', {sentDuringComposition: during, sentAfterCommit: 1, note: 'CDP Input.imeSetComposition — not a real OS IME; a manual macOS/Windows pass is still required.'}) + expect(during).toBe(0) +}) diff --git a/everwith-claude-frontend/e2e/live2d.spec.ts b/everwith-claude-frontend/e2e/live2d.spec.ts new file mode 100644 index 00000000..43f92aa5 --- /dev/null +++ b/everwith-claude-frontend/e2e/live2d.spec.ts @@ -0,0 +1,169 @@ +/** + * Live2D evidence for the five official characters on the character page: + * 1× large-face framing, ≥4 fps frame sampling for ~30 s (motion continuity), expression + * cues (happy / surprised / back to neutral), channel inventory (no eyeball writes), + * and lifecycle (switching characters releases runtimes; context loss recovers). + * Software GL (SwiftShader) in CI: timings are slower than a real GPU. + */ +import {expect, test} from '@playwright/test' +import type {Page} from '@playwright/test' +import {writeFileSync} from 'node:fs' +import {evidencePath, writeJSON} from './helpers' + +const KEYS = ['lena', 'arthur', 'marcus', 'nora', 'iris'] as const +const SAMPLE_SECONDS = Number(process.env.LIVE2D_SAMPLE_SECONDS ?? 30) + +type Snapshot = {active: number; created: number; disposed: number; framesDrawn: number; frameErrors?: number; skippedHidden?: number; runtimes: {character: string; status: string; lastError?: string | null; frames: number; avgFrameMs: number; maxFrameMs: number; expression: string | null; channels: string[]; extras: string[]; contextLosses: number; restores: number}[]} +const snap = (page: Page) => page.evaluate(() => window.__everwithLive2D!.snapshot() as unknown as Snapshot) +const saveDataUrl = (url: string, path: string) => writeFileSync(path, Buffer.from(url.split(',')[1], 'base64')) + +async function sample(page: Page, seconds: number) { + return page.evaluate(async secs => { + const canvas = document.querySelector('.ew-stage canvas:not([hidden])')! + const small = document.createElement('canvas'); small.width = 96; small.height = 128 + const ctx = small.getContext('2d', {willReadFrequently: true})! + const thumbs: {t: number; url: string}[] = [] + const diffs: {t: number; diff: number}[] = [] + let prev: Uint8ClampedArray | null = null + const start = performance.now() + const thumb = () => { const c = document.createElement('canvas'); c.width = 240; c.height = Math.round(240 * canvas.height / canvas.width); c.getContext('2d')!.drawImage(canvas, 0, 0, c.width, c.height); return c.toDataURL('image/jpeg', .82) } + await new Promise(resolve => { + let n = 0 + const tick = () => { + const t = (performance.now() - start) / 1000 + ctx.clearRect(0, 0, 96, 128); ctx.drawImage(canvas, 0, 0, 96, 128) + const data = ctx.getImageData(0, 0, 96, 128).data + if (prev) { let s = 0; for (let i = 0; i < data.length; i += 4) s += Math.abs(data[i] - prev[i]) + Math.abs(data[i + 1] - prev[i + 1]) + Math.abs(data[i + 2] - prev[i + 2]); diffs.push({t, diff: s / (96 * 128 * 3)}) } + prev = data + if (n % 20 === 0) thumbs.push({t, url: thumb()}) + n++ + if (t >= secs) { thumbs.push({t, url: thumb()}); resolve() } else setTimeout(tick, Math.max(0, start + n * 200 - performance.now())) // 5 Hz schedule, drift-compensated + } + tick() + }) + return {samples: diffs.length + 1, fps: (diffs.length + 1) / secs, diffs, thumbs} + }, seconds) +} + +test.describe.configure({mode: 'serial'}) + +for (const key of KEYS) { + test(`${key}: big face, continuous idle motion, expressions, channel inventory @long`, async ({page}) => { + test.setTimeout((SAMPLE_SECONDS + 90) * 1000) + await page.goto(`/#/characters/official-${key}?v=1`) + await expect(page.locator('.ew-stage[data-live2d-status="ready"]')).toBeVisible({timeout: 30_000}) + await page.getByRole('button', {name: '放大'}).or(page.locator('.ew-stage-zoom')).first().click() + await page.waitForTimeout(1500) + const stage = page.locator('.ew-character-stage') + await stage.screenshot({path: evidencePath('live2d', key, '1x-large-face.png')}) + const s = await sample(page, SAMPLE_SECONDS) + s.thumbs.forEach((th, i) => saveDataUrl(th.url, evidencePath('live2d', key, `frame-${String(i).padStart(2, '0')}-t${th.t.toFixed(1)}s.jpg`))) + const diffs = s.diffs.map(d => d.diff) + const moving = diffs.filter(d => d > 0.05).length / diffs.length + const maxJump = Math.max(...diffs) + const mean = diffs.reduce((a, b) => a + b, 0) / diffs.length + // Expressions are driven exactly like a committed cue would drive them. + const faces: Record = {} + for (const e of ['happy', 'surprised', 'neutral'] as const) { + await page.evaluate(([k, x]) => window.__everwithLive2D!.debugSetExpression(k, x), [key, e] as const) + await page.waitForTimeout(1200) + await stage.screenshot({path: evidencePath('live2d', key, `expression-${e}.png`)}) + faces[e] = (await snap(page)).runtimes.find(r => r.character === key)?.expression ?? null + } + const rt = (await snap(page)).runtimes.find(r => r.character === key)! + const eyeball = rt.channels.filter(c => /EyeBall/i.test(c)) + const report = {character: key, sampleSeconds: SAMPLE_SECONDS, samples: s.samples, fps: Number(s.fps.toFixed(2)), + motion: {fractionOfSamplesWithChange: Number(moving.toFixed(3)), meanAbsDiff: Number(mean.toFixed(3)), maxAbsDiff: Number(maxJump.toFixed(3))}, + expressionsReported: faces, channels: rt.channels, extras: rt.extras, eyeballChannels: eyeball, + frameStats: {frames: rt.frames, avgFrameMs: rt.avgFrameMs, maxFrameMs: rt.maxFrameMs}, environment: 'headless Chromium + SwiftShader (software GL)'} + writeJSON(`live2d-${key}`, report) + writeFileSync(evidencePath('live2d', key, 'diffs.json'), JSON.stringify(s.diffs)) + expect(s.fps).toBeGreaterThanOrEqual(3.5) + expect(moving).toBeGreaterThan(0.5) // keeps moving (idle never freezes) + expect(faces.happy).toBe('happy') + expect(faces.surprised).toBe('surprised') + expect(faces.neutral).toBe('neutral') + expect(eyeball).toEqual([]) // no eyeball/gaze writes (Nora gaze OFF; others not driven) + }) +} + +test('lifecycle: switching, zoom, hidden tab, off-screen and the table rail release or pause runtimes; a lost WebGL context recovers', async ({page}) => { + test.setTimeout(240_000) + const frames = async (key: string) => (await snap(page)).runtimes.find(r => r.character === key)?.frames ?? 0 + for (const key of [...KEYS, ...KEYS]) { + await page.goto(`/#/characters/official-${key}?v=1`) + await expect(page.locator('.ew-stage[data-live2d-status="ready"]')).toBeVisible({timeout: 30_000}) + } + const a = await snap(page) + // Zoom in / out four times on the same character: one live runtime at a time. + for (let i = 0; i < 4; i++) { + await page.locator('.ew-stage-zoom').click() + await expect(page.locator('.ew-stage[data-live2d-status="ready"]')).toBeVisible({timeout: 30_000}) + } + const z = await snap(page) + // Hidden tab: no draws while hidden, drawing resumes when visible again. + await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => 'hidden'}); document.dispatchEvent(new Event('visibilitychange')) }) + const h0 = await frames('iris'); await page.waitForTimeout(1500); const h1 = await frames('iris') + await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => 'visible'}); document.dispatchEvent(new Event('visibilitychange')) }) + await page.waitForTimeout(1500); const h2 = await frames('iris') + // Off-screen stage: the observer pauses it; back on screen it resumes. + await page.evaluate(() => { document.querySelector('.ew-stage')!.style.transform = 'translateY(-6000px)' }) + await page.waitForTimeout(700); const o0 = await frames('iris'); await page.waitForTimeout(1500); const o1 = await frames('iris') + await page.evaluate(() => { document.querySelector('.ew-stage')!.style.transform = '' }) + await page.waitForTimeout(1500); const o2 = await frames('iris') + await page.goto('/#/discover') + await page.waitForTimeout(800) + const b = await snap(page) + // Table rail: open/close the people rail three times on a game table. + await page.goto('/#/games/cabo/prepare') + await page.locator('.ew-start-btn').click() + await page.waitForURL(/#\/play\/game\//) + for (let i = 0; i < 6; i++) { await page.getByRole('button', {name: '人物与桌聊'}).click(); await page.waitForTimeout(700) } + await page.waitForTimeout(1500) + const r = await snap(page) + await page.goto('/#/discover') + await page.waitForTimeout(800) + const r2 = await snap(page) + // Context loss on a fresh stage. + await page.goto('/#/characters/official-lena?v=1') + await expect(page.locator('.ew-stage[data-live2d-status="ready"]')).toBeVisible({timeout: 30_000}) + const lost = await page.evaluate(async () => { + const c = document.querySelector('.ew-stage canvas:not([hidden])')! + const gl = c.getContext('webgl')! + const ext = gl.getExtension('WEBGL_lose_context')! + ext.loseContext() + await new Promise(r => setTimeout(r, 600)) + ext.restoreContext() + await new Promise(r => setTimeout(r, 2500)) + return true + }) + const c = await snap(page) + const lena = c.runtimes.find(r => r.character === 'lena') + const framesBefore = lena?.frames ?? 0 + await page.waitForTimeout(1500) + const framesAfter = await frames('lena') + const memory = await page.evaluate(() => (performance as unknown as {memory?: {usedJSHeapSize: number}}).memory?.usedJSHeapSize ?? null) + writeJSON('live2d-lifecycle', {afterTenSwitches: {active: a.active, created: a.created, disposed: a.disposed}, + afterFourZoomToggles: {active: z.active, created: z.created, disposed: z.disposed}, + hiddenTab: {framesAtHide: h0, after1500msHidden: h1, after1500msVisible: h2, skippedHidden: (await snap(page)).skippedHidden}, + offscreen: {framesAtOffscreen: o0, after1500msOffscreen: o1, after1500msBack: o2}, + afterLeaving: {active: b.active, created: b.created, disposed: b.disposed}, + tableRailSixToggles: {activeOnTable: r.active, createdMinusDisposedOnTable: r.created - r.disposed, afterLeavingTable: {active: r2.active, created: r2.created, disposed: r2.disposed}}, + contextLoss: {simulated: lost, contextLosses: lena?.contextLosses, restores: lena?.restores, status: lena?.status, lastError: lena?.lastError ?? null, frameErrors: c.frameErrors ?? 0, framesBefore, framesAfter, framesAdvancingAfterRestore: framesAfter > framesBefore}, + usedJSHeapBytesAtEnd: memory, environment: 'headless Chromium + SwiftShader (software GL)'}) + expect(a.active).toBe(1) + expect(z.active).toBe(1) + expect(z.created - z.disposed).toBe(1) + expect(h1 - h0).toBeLessThanOrEqual(1) + expect(h2).toBeGreaterThan(h1) + expect(o1 - o0).toBeLessThanOrEqual(1) + expect(o2).toBeGreaterThan(o1) + expect(b.active).toBe(0) + expect(b.created - b.disposed).toBe(0) + expect(r.created - r.disposed).toBe(r.active) + expect(r.active).toBeLessThanOrEqual(1) + expect(r2.active).toBe(0) + expect(r2.created - r2.disposed).toBe(0) + expect(lena?.contextLosses).toBeGreaterThanOrEqual(1) + expect(framesAfter).toBeGreaterThan(framesBefore) +}) diff --git a/everwith-claude-frontend/e2e/story-create.spec.ts b/everwith-claude-frontend/e2e/story-create.spec.ts new file mode 100644 index 00000000..fabae6d0 --- /dev/null +++ b/everwith-claude-frontend/e2e/story-create.spec.ts @@ -0,0 +1,110 @@ +/** + * Story run (prepare → play → continue → rollback → befriend the current person) and the + * creation studio (type → name → edit → AI-template assist + undo → isolated preview → + * stale preview → sign-in → save → explicit share → befriend v1 → chat). + */ +import {expect, test} from '@playwright/test' +import {shot, signUp, writeJSON} from './helpers' + +test('story: explicit start, committed turns only, continue, rollback restores the draft, befriend the person in this run', async ({page}) => { + await page.goto('/#/stories/story-promise-rain?v=1') + await shot(page, 'story-detail') + await page.getByTestId('story-prepare').click() + await expect(page.getByText('确认前不会创建任何记录。').first()).toBeVisible() + await shot(page, 'story-prepare') + await page.getByTestId('story-start').click() + await expect(page.getByTestId('story-play')).toBeVisible() + const scene = page.locator('.ew-story-scene') + await expect(scene).toHaveAttribute('data-scene', 'entrance-rain') + const input = page.locator('.ew-story-main textarea') + await input.fill('我凑近看看那份名单上的墨迹') + await input.press('Enter') + await expect(page.getByTestId('story-pending')).toBeVisible() + await shot(page, 'story-validating') + await expect(page.getByTestId('story-pending')).toBeHidden({timeout: 15_000}) + await expect(page.getByText('新线索:未干的划线墨迹')).toBeVisible() + const playerLines = page.locator('.ew-story-main .ew-msg.is-player:not(.is-provisional)') + await expect(playerLines).toHaveCount(1) + await page.getByTestId('story-continue-btn').click() + await expect(page.getByTestId('story-pending')).toBeHidden({timeout: 15_000}) + await expect(page.locator('.ew-objectives li.is-done')).toHaveCount(2) + await expect(playerLines).toHaveCount(1) // “Continue” never invents a player line + await expect(scene).toHaveAttribute('data-scene', 'backstage') // the committed beat moved the scene + await shot(page, 'story-after-continue') + const revBefore = Number(await page.getByTestId('story-play').getAttribute('data-revision')) + + // Roll back to the first action: text + state withdraw together, the action returns as a draft. + await page.locator('.ew-turn').nth(1).hover() + await page.getByRole('button', {name: '回退到这里'}).first().click() + await shot(page, 'story-rollback-confirm') + await page.locator('dialog[open] .ew-btn.danger').click() + await expect(input).toHaveValue('我凑近看看那份名单上的墨迹') + await expect(page.locator('.ew-objectives li.is-done')).toHaveCount(0) + await expect(scene).toHaveAttribute('data-scene', 'entrance-rain') // scene withdrawn with the text + const revAfter = Number(await page.getByTestId('story-play').getAttribute('data-revision')) + expect(revAfter).toBeGreaterThan(revBefore) + + // Retryable validation failure: no half turn; explicit retry of the same turn. + await page.evaluate(() => sessionStorage.setItem('everwith.mock.scenarios', JSON.stringify({'story.retryable_failure': true}))) + await page.reload() + await input.press('Enter') + await expect(page.getByText('这一轮没有完成(可重试,未写入任何内容)。')).toBeVisible({timeout: 15_000}) + await shot(page, 'story-retryable') + await page.getByTestId('story-retry').click() + await expect(page.getByText('新线索:未干的划线墨迹')).toBeVisible({timeout: 15_000}) + + // F2: befriend THIS person (Marcus as stage manager) — sign-in in the middle, same run continues. + await page.getByTestId('story-befriend-stage').click() + await page.getByRole('button', {name: '确认加为好友'}).click() + await signUp(page, 'e2e-story@example.com') + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page.locator('.ew-story-cast-row[data-role="stage"]')).toContainText('好友') + await shot(page, 'story-befriended-in-run') + writeJSON('story-run', {ok: true, revBefore, revAfter}) +}) + +test('creation studio: type → name → edit → assist/undo → isolated preview → stale → save → share → befriend v1', async ({page}) => { + await page.goto('/#/create') + await expect(page.getByText('你还没有作品。')).toBeVisible() + await page.getByTestId('create-new').click() + await expect(page.getByTestId('work-create')).toBeDisabled() + await page.getByTestId('type-character').click() + await page.getByTestId('work-name').fill('晚风') + await shot(page, 'create-type-and-name') + await page.getByTestId('work-create').click() + await expect(page.getByTestId('work-editor')).toBeVisible() + await expect(page.getByTestId('preview-start')).toBeDisabled() // required fields missing + await page.getByTestId('assist-fill').click() + await expect(page.getByTestId('field-persona')).not.toHaveValue('') + await page.getByTestId('assist-undo').click() + await expect(page.getByTestId('field-persona')).toHaveValue('') + await page.getByTestId('assist-fill').click() + await expect(page.getByTestId('sync-status')).toHaveText('草稿已同步', {timeout: 10_000}) + await page.getByTestId('preview-start').click() + const say = page.locator('.ew-preview textarea') + await say.fill('你好,你平时喜欢做什么?') + await say.press('Enter') + await expect(page.locator('.ew-preview .ew-msg.is-player')).toHaveCount(1) + await page.getByTestId('field-habit').fill('说话很慢,喜欢停顿') + await expect(page.getByTestId('preview-stale')).toBeVisible({timeout: 10_000}) + await shot(page, 'create-preview-stale') + await page.getByTestId('work-save').click() + await signUp(page, 'e2e-create@example.com') + await page.getByTestId('work-save').click() + await expect(page.getByText('已保存 v1').first()).toBeVisible() + await page.getByTestId('share-on').click() + await expect(page.getByText('已分享 v1').first()).toBeVisible() + await shot(page, 'create-saved-shared') + await page.getByTestId('work-befriend').click() + await page.getByRole('button', {name: '确认加为好友'}).click() + await expect(page).toHaveURL(/#\/friends\/fr_/) + // A self-made character has no room of its own: the generic generated backdrop, never an official one. + await expect(page.locator('.ew-chat-portrait .ew-backdrop img')).toHaveAttribute('src', '/generated/batch-01/backdrop-generic.webp') + // The isolated preview never leaks into the friend's real conversation (no test lines as memory). + await expect(page.locator('.ew-chat-main')).not.toContainText('你好,你平时喜欢做什么?') + const box = page.locator('.ew-chat-main textarea') + await box.fill('你好呀') + await box.press('Enter') + await expect(page.locator('.ew-msg.is-character:not(.is-provisional)').last()).toBeVisible({timeout: 20_000}) + await shot(page, 'create-friend-chat') +}) diff --git a/everwith-claude-frontend/index.html b/everwith-claude-frontend/index.html new file mode 100644 index 00000000..8b3e85e0 --- /dev/null +++ b/everwith-claude-frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Everwith · 本地演示 + + + + + +
+ + + diff --git a/everwith-claude-frontend/package-lock.json b/everwith-claude-frontend/package-lock.json new file mode 100644 index 00000000..1dc93d4c --- /dev/null +++ b/everwith-claude-frontend/package-lock.json @@ -0,0 +1,3056 @@ +{ + "name": "everwith-claude-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "everwith-claude-frontend", + "version": "0.1.0", + "dependencies": { + "lucide-react": "0.545.0", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "@playwright/test": "1.56.0", + "@testing-library/react": "16.3.0", + "@types/node": "22.18.12", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@vitejs/plugin-react": "5.1.0", + "jsdom": "27.0.1", + "typescript": "5.9.3", + "vite": "7.1.12", + "vitest": "4.0.6" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.6.tgz", + "integrity": "sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.6.tgz", + "integrity": "sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.6.tgz", + "integrity": "sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.6.tgz", + "integrity": "sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.6.tgz", + "integrity": "sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.6.tgz", + "integrity": "sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.6.tgz", + "integrity": "sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.6.tgz", + "integrity": "sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.6.tgz", + "integrity": "sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.6.tgz", + "integrity": "sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.6.tgz", + "integrity": "sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.6.tgz", + "integrity": "sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.6.tgz", + "integrity": "sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.6.tgz", + "integrity": "sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.6.tgz", + "integrity": "sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.6.tgz", + "integrity": "sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.6.tgz", + "integrity": "sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.6.tgz", + "integrity": "sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.6.tgz", + "integrity": "sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.6.tgz", + "integrity": "sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.6.tgz", + "integrity": "sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.6.tgz", + "integrity": "sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.6.tgz", + "integrity": "sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.6.tgz", + "integrity": "sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.6.tgz", + "integrity": "sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", + "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.6.tgz", + "integrity": "sha512-5j8UUlBVhOjhj4lR2Nt9sEV8b4WtbcYh8vnfhTNA2Kn5+smtevzjNq+xlBuVhnFGXiyPPNzGrOVvmyHWkS5QGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.6", + "@vitest/utils": "4.0.6", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.6.tgz", + "integrity": "sha512-3COEIew5HqdzBFEYN9+u0dT3i/NCwppLnO1HkjGfAP1Vs3vti1Hxm/MvcbC4DAn3Szo1M7M3otiAaT83jvqIjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.19" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.6.tgz", + "integrity": "sha512-4vptgNkLIA1W1Nn5X4x8rLJBzPiJwnPc+awKtfBE5hNMVsoAl/JCCPPzNrbf+L4NKgklsis5Yp2gYa+XAS442g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.6.tgz", + "integrity": "sha512-trPk5qpd7Jj+AiLZbV/e+KiiaGXZ8ECsRxtnPnCrJr9OW2mLB72Cb824IXgxVz/mVU3Aj4VebY+tDTPn++j1Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.6.tgz", + "integrity": "sha512-PaYLt7n2YzuvxhulDDu6c9EosiRuIE+FI2ECKs6yvHyhoga+2TBWI8dwBjs+IeuQaMtZTfioa9tj3uZb7nev1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.6", + "magic-string": "^0.30.19", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.6.tgz", + "integrity": "sha512-g9jTUYPV1LtRPRCQfhbMintW7BTQz1n6WXYQYRQ25qkyffA4bjVXjkROokZnv7t07OqfaFKw1lPzqKGk1hmNuQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.6.tgz", + "integrity": "sha512-bG43VS3iYKrMIZXBo+y8Pti0O7uNju3KvNn6DrQWhQQKcLavMB+0NZfO1/QBAEbq0MaQ3QjNsnnXlGQvsh0Z6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.6", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.454", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.454.tgz", + "integrity": "sha512-NqD5GhkXnxJpDQVCmmpc546qjH6kei2VI9m45CTF+sQU7bcS7YeVt7ba0cnjueMH1AXGRu/driZUP9iYVSv7vg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.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", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "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/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.6.tgz", + "integrity": "sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.6", + "@rollup/rollup-android-arm64": "4.64.6", + "@rollup/rollup-darwin-arm64": "4.64.6", + "@rollup/rollup-darwin-x64": "4.64.6", + "@rollup/rollup-freebsd-arm64": "4.64.6", + "@rollup/rollup-freebsd-x64": "4.64.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.6", + "@rollup/rollup-linux-arm-musleabihf": "4.64.6", + "@rollup/rollup-linux-arm64-gnu": "4.64.6", + "@rollup/rollup-linux-arm64-musl": "4.64.6", + "@rollup/rollup-linux-loong64-gnu": "4.64.6", + "@rollup/rollup-linux-loong64-musl": "4.64.6", + "@rollup/rollup-linux-ppc64-gnu": "4.64.6", + "@rollup/rollup-linux-ppc64-musl": "4.64.6", + "@rollup/rollup-linux-riscv64-gnu": "4.64.6", + "@rollup/rollup-linux-riscv64-musl": "4.64.6", + "@rollup/rollup-linux-s390x-gnu": "4.64.6", + "@rollup/rollup-linux-x64-gnu": "4.64.6", + "@rollup/rollup-linux-x64-musl": "4.64.6", + "@rollup/rollup-openbsd-x64": "4.64.6", + "@rollup/rollup-openharmony-arm64": "4.64.6", + "@rollup/rollup-win32-arm64-msvc": "4.64.6", + "@rollup/rollup-win32-ia32-msvc": "4.64.6", + "@rollup/rollup-win32-x64-gnu": "4.64.6", + "@rollup/rollup-win32-x64-msvc": "4.64.6", + "fsevents": "~2.3.2" + } + }, + "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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.6.tgz", + "integrity": "sha512-gR7INfiVRwnEOkCk47faros/9McCZMp5LM+OMNWGLaDBSvJxIzwjgNFufkuePBNaesGRnLmNfW+ddbUJRZn0nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.6", + "@vitest/mocker": "4.0.6", + "@vitest/pretty-format": "4.0.6", + "@vitest/runner": "4.0.6", + "@vitest/snapshot": "4.0.6", + "@vitest/spy": "4.0.6", + "@vitest/utils": "4.0.6", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.6", + "@vitest/browser-preview": "4.0.6", + "@vitest/browser-webdriverio": "4.0.6", + "@vitest/ui": "4.0.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "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": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "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" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/everwith-claude-frontend/package.json b/everwith-claude-frontend/package.json new file mode 100644 index 00000000..e12bcde3 --- /dev/null +++ b/everwith-claude-frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "everwith-claude-frontend", + "private": true, + "version": "0.1.0", + "description": "Independent Everwith desktop-first frontend rebuild (local mock adapter; no live services).", + "type": "module", + "engines": { + "node": ">=20.19.0" + }, + "scripts": { + "assets:sync": "node scripts/sync-assets.mjs", + "assets:check": "node scripts/sync-assets.mjs --check", + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173 --strictPort", + "check": "tsc -p tsconfig.json --noEmit && vitest run", + "test": "vitest run", + "test:e2e": "playwright test", + "rules:check": "vitest run tests/engines.test.ts", + "test:e2e:quick": "playwright test --grep-invert @long", + "assets:refs": "node scripts/check-asset-refs.mjs", + "assets:generated": "node scripts/check-generated.mjs" + }, + "dependencies": { + "lucide-react": "0.545.0", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "@playwright/test": "1.56.0", + "@testing-library/react": "16.3.0", + "@types/node": "22.18.12", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@vitejs/plugin-react": "5.1.0", + "jsdom": "27.0.1", + "typescript": "5.9.3", + "vite": "7.1.12", + "vitest": "4.0.6" + } +} diff --git a/everwith-claude-frontend/playwright.config.ts b/everwith-claude-frontend/playwright.config.ts new file mode 100644 index 00000000..22f625f2 --- /dev/null +++ b/everwith-claude-frontend/playwright.config.ts @@ -0,0 +1,36 @@ +import {defineConfig} from '@playwright/test' + +/** + * E2E against the production build served locally (`vite preview`, 127.0.0.1:4173). + * Everything runs on the in-browser mock adapter; no network service is contacted. + * On Linux CI without a GPU, WebGL (Live2D) uses SwiftShader software rendering. + */ +const softwareGL = process.platform === 'linux' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'] : [] + +export default defineConfig({ + testDir: 'e2e', + outputDir: 'test-results', + timeout: 120_000, + expect: {timeout: 15_000}, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list'], ['json', {outputFile: 'test-results/e2e-report.json'}]], + use: { + baseURL: 'http://127.0.0.1:4173', + channel: 'chromium', + viewport: {width: 1280, height: 800}, + locale: 'zh-CN', + timezoneId: 'Asia/Shanghai', + launchOptions: {args: softwareGL}, + // Tracing screencasts every frame; with software-GL Live2D that starves timers. Opt in with TRACE=1. + trace: process.env.TRACE ? 'retain-on-failure' : 'off', + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run build && npm run preview', + url: 'http://127.0.0.1:4173', + reuseExistingServer: true, + timeout: 120_000, + }, +}) diff --git a/everwith-claude-frontend/public/generated/batch-01/apple-touch-icon.png b/everwith-claude-frontend/public/generated/batch-01/apple-touch-icon.png new file mode 100644 index 00000000..f64ba929 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/apple-touch-icon.png differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-arthur.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-arthur.webp new file mode 100644 index 00000000..9e87cf67 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-arthur.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-generic.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-generic.webp new file mode 100644 index 00000000..3a00ae66 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-generic.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-iris.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-iris.webp new file mode 100644 index 00000000..82ce6bc7 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-iris.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-lena.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-lena.webp new file mode 100644 index 00000000..8487038e Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-lena.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-marcus.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-marcus.webp new file mode 100644 index 00000000..4e3d1ba0 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-marcus.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/backdrop-nora.webp b/everwith-claude-frontend/public/generated/batch-01/backdrop-nora.webp new file mode 100644 index 00000000..ed176367 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/backdrop-nora.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/brand-emblem.png b/everwith-claude-frontend/public/generated/batch-01/brand-emblem.png new file mode 100644 index 00000000..c163e2f2 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/brand-emblem.png differ diff --git a/everwith-claude-frontend/public/generated/batch-01/create-type-character.webp b/everwith-claude-frontend/public/generated/batch-01/create-type-character.webp new file mode 100644 index 00000000..52d30e97 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/create-type-character.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/create-type-story.webp b/everwith-claude-frontend/public/generated/batch-01/create-type-story.webp new file mode 100644 index 00000000..e128734c Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/create-type-story.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/empty-create.webp b/everwith-claude-frontend/public/generated/batch-01/empty-create.webp new file mode 100644 index 00000000..a3bd68e4 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/empty-create.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/empty-friends.webp b/everwith-claude-frontend/public/generated/batch-01/empty-friends.webp new file mode 100644 index 00000000..173a9180 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/empty-friends.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/empty-recent.webp b/everwith-claude-frontend/public/generated/batch-01/empty-recent.webp new file mode 100644 index 00000000..3ed6e6a7 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/empty-recent.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/favicon-64.png b/everwith-claude-frontend/public/generated/batch-01/favicon-64.png new file mode 100644 index 00000000..6eb5e88b Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/favicon-64.png differ diff --git a/everwith-claude-frontend/public/generated/batch-01/game-cabo-cover.webp b/everwith-claude-frontend/public/generated/batch-01/game-cabo-cover.webp new file mode 100644 index 00000000..a57c5897 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/game-cabo-cover.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/game-royale-cover.webp b/everwith-claude-frontend/public/generated/batch-01/game-royale-cover.webp new file mode 100644 index 00000000..ce0d8e04 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/game-royale-cover.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/game-splendor-cover.webp b/everwith-claude-frontend/public/generated/batch-01/game-splendor-cover.webp new file mode 100644 index 00000000..b5cf54f7 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/game-splendor-cover.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-01.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-01.webp new file mode 100644 index 00000000..c4aeab0a Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-01.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-02.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-02.webp new file mode 100644 index 00000000..7388698a Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-02.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-03.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-03.webp new file mode 100644 index 00000000..935c3c25 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-03.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-04.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-04.webp new file mode 100644 index 00000000..44c3864a Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-04.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-05.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-05.webp new file mode 100644 index 00000000..141dc223 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-05.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-06.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-06.webp new file mode 100644 index 00000000..9bf1ba9f Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-06.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-07.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-07.webp new file mode 100644 index 00000000..40af8f23 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-07.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/persona-avatar-08.webp b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-08.webp new file mode 100644 index 00000000..7afe39a8 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/persona-avatar-08.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/state-load-error.webp b/everwith-claude-frontend/public/generated/batch-01/state-load-error.webp new file mode 100644 index 00000000..bd475b4b Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/state-load-error.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/state-not-found.webp b/everwith-claude-frontend/public/generated/batch-01/state-not-found.webp new file mode 100644 index 00000000..170500f1 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/state-not-found.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/state-signin.webp b/everwith-claude-frontend/public/generated/batch-01/state-signin.webp new file mode 100644 index 00000000..db8f0b7e Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/state-signin.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-tall.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-tall.webp new file mode 100644 index 00000000..321a6b2e Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-tall.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-wide.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-wide.webp new file mode 100644 index 00000000..3eff1e64 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-cover-wide.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-corner.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-corner.webp new file mode 100644 index 00000000..c9f7dc53 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-corner.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-duty-room.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-duty-room.webp new file mode 100644 index 00000000..a88f1e1f Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-duty-room.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-platform-dawn.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-platform-dawn.webp new file mode 100644 index 00000000..12f5cbdd Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-platform-dawn.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-waiting-room.webp b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-waiting-room.webp new file mode 100644 index 00000000..9b06084f Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-last-train-scene-waiting-room.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-tall.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-tall.webp new file mode 100644 index 00000000..fd105f90 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-tall.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-wide.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-wide.webp new file mode 100644 index 00000000..0ffb9a8a Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-cover-wide.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-backstage.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-backstage.webp new file mode 100644 index 00000000..9547a9f2 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-backstage.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp new file mode 100644 index 00000000..de39bb41 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-after-rain.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-rain.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-rain.webp new file mode 100644 index 00000000..128be1ca Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-entrance-rain.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-office.webp b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-office.webp new file mode 100644 index 00000000..ae7a4c6b Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/story-promise-rain-scene-office.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-01.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-01.webp new file mode 100644 index 00000000..d281d7e0 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-01.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-02.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-02.webp new file mode 100644 index 00000000..239516f1 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-02.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-03.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-03.webp new file mode 100644 index 00000000..0e5926cb Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-03.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-04.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-04.webp new file mode 100644 index 00000000..62ab6c38 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-04.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-05.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-05.webp new file mode 100644 index 00000000..c409b7e2 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-05.webp differ diff --git a/everwith-claude-frontend/public/generated/batch-01/work-cover-06.webp b/everwith-claude-frontend/public/generated/batch-01/work-cover-06.webp new file mode 100644 index 00000000..cca63bf2 Binary files /dev/null and b/everwith-claude-frontend/public/generated/batch-01/work-cover-06.webp differ diff --git a/everwith-claude-frontend/scripts/check-asset-refs.mjs b/everwith-claude-frontend/scripts/check-asset-refs.mjs new file mode 100644 index 00000000..75629bfc --- /dev/null +++ b/everwith-claude-frontend/scripts/check-asset-refs.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Finds every `/handoff/...` asset path written in src/ — plain literals and template +// strings built from per-file constants such as `const P = '/handoff/assets/...'` — and +// verifies each file exists in public/handoff (copied by `npm run assets:sync`). +// Paths assembled from data at runtime (Live2D model3 references, CABO ranks from the +// manifest) are verified by the sync script itself. +import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const files = []; +(function walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { if (name !== 'vendor') walk(p); } + else if (/\.(ts|tsx|css)$/.test(name)) files.push(p); + } +})(join(root, 'src')); + +const EXT = '(?:png|jpe?g|webp|mp4|json|js|moc3)'; +const refs = new Map(); +const add = (rel, file) => refs.set(rel, [...new Set([...(refs.get(rel) ?? []), file.slice(root.length + 1)])]); +for (const f of files) { + const text = readFileSync(f, 'utf8'); + for (const m of text.matchAll(new RegExp(`/handoff/(assets/[A-Za-z0-9_\\-./]+\\.${EXT})(?![A-Za-z0-9_])`, 'g'))) add(m[1], f); + // Per-file directory constants, e.g. const V = '/handoff/assets/companions/game-previews/v1' + const consts = new Map([...text.matchAll(/(?:export\s+)?const\s+([A-Z_][A-Z0-9_]*)\s*=\s*['`]\/handoff\/([^'`$]+?)\/?['`]/g)].map(m => [m[1], m[2]])); + consts.set('GAME_ASSETS', 'assets/games'); + for (const m of text.matchAll(new RegExp(`\\$\\{([A-Z_][A-Z0-9_]*)\\}/([A-Za-z0-9_\\-./]+\\.${EXT})(?![A-Za-z0-9_])`, 'g'))) { + const base = consts.get(m[1]) ?? (m[1] === 'DIR' ? 'assets/games/splendor' : m[1] === 'ART_DIR' ? 'assets/games/cabo/card-rank-art' : null); + if (base) add(`${base}/${m[2]}`, f); + } +} +const missing = [...refs.keys()].filter(rel => !existsSync(join(root, 'public', 'handoff', rel))); +console.log(JSON.stringify({checkedRefs: refs.size, missing: missing.map(rel => ({rel, from: refs.get(rel)}))}, null, 2)); +process.exit(missing.length ? 1 : 0); diff --git a/everwith-claude-frontend/scripts/check-generated.mjs b/everwith-claude-frontend/scripts/check-generated.mjs new file mode 100644 index 00000000..ea2bbe7e --- /dev/null +++ b/everwith-claude-frontend/scripts/check-generated.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Verifies the user-provided generated images (GPT batch v2-batch-01) that ship in +// public/generated/batch-01 against docs/GENERATED_ASSETS.json: +// - every listed derivative exists with the recorded sha256 and size, nothing unlisted is served; +// - every `/generated/batch-01/` written in src/ or index.html (literal or `${GEN}/`) is listed; +// - the id families picked at runtime (stage backdrops, work covers, persona avatars) are complete. +// Rights of these images: 用户经 GPT 生成并提供;商用/再分发权未核实 (not part of the handoff package). +import {createHash} from 'node:crypto'; +import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs'; +import {dirname, join, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const doc = JSON.parse(readFileSync(join(root, 'docs', 'GENERATED_ASSETS.json'), 'utf8')); +const problems = []; +const listed = new Map(doc.assets.map(a => [a.derivative.url.replace('/generated/batch-01/', ''), a])); + +for (const [name, a] of listed) { + const file = join(root, a.derivative.path); + if (!existsSync(file)) { problems.push({file: a.derivative.path, problem: 'missing'}); continue; } + const bytes = readFileSync(file); + const sha = createHash('sha256').update(bytes).digest('hex'); + if (sha !== a.derivative.sha256) problems.push({file: a.derivative.path, problem: 'sha256 mismatch'}); + if (bytes.length !== a.derivative.bytes) problems.push({file: a.derivative.path, problem: 'size mismatch'}); + if (!a.original?.sha256 || !a.original?.file) problems.push({file: name, problem: 'no original recorded'}); +} +const servedDir = join(root, 'public', 'generated', 'batch-01'); +for (const name of existsSync(servedDir) ? readdirSync(servedDir) : []) if (!listed.has(name)) problems.push({file: `public/generated/batch-01/${name}`, problem: 'not listed in docs/GENERATED_ASSETS.json'}); + +const files = [join(root, 'index.html')]; +(function walk(dir) { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { if (name !== 'vendor') walk(p); } + else if (/\.(ts|tsx|css)$/.test(name)) files.push(p); + } +})(join(root, 'src')); +const refs = new Map(); +for (const f of files) { + const text = readFileSync(f, 'utf8'); + const rel = f.slice(root.length + 1); + for (const m of text.matchAll(/\/generated\/batch-01\/([A-Za-z0-9_.-]+\.(?:webp|png))/g)) refs.set(m[1], [...new Set([...(refs.get(m[1]) ?? []), rel])]); + if (/const\s+GEN\s*=\s*'\/generated\/batch-01'/.test(text) || /from '[./]*art\/generated'/.test(text)) { + for (const m of text.matchAll(/\$\{GEN\}\/([A-Za-z0-9_.-]+\.(?:webp|png))/g)) refs.set(m[1], [...new Set([...(refs.get(m[1]) ?? []), rel])]); + } +} +for (const [name, from] of refs) if (!listed.has(name)) problems.push({file: name, problem: 'referenced but not listed', from}); + +const families = [ + ...['arthur', 'lena', 'marcus', 'nora', 'iris', 'generic'].map(k => `backdrop-${k}.webp`), + ...[1, 2, 3, 4, 5, 6].map(i => `work-cover-0${i}.webp`), + ...[1, 2, 3, 4, 5, 6, 7, 8].map(i => `persona-avatar-0${i}.webp`), +]; +for (const name of families) if (!listed.has(name)) problems.push({file: name, problem: 'runtime id family incomplete'}); + +console.log(JSON.stringify({batch: doc.batch, rights: doc.rights, derivatives: listed.size, staticRefs: refs.size, runtimeFamilies: families.length, problems}, null, 2)); +process.exit(problems.length ? 1 : 0); diff --git a/everwith-claude-frontend/scripts/derive-generated.py b/everwith-claude-frontend/scripts/derive-generated.py new file mode 100644 index 00000000..af556534 --- /dev/null +++ b/everwith-claude-frontend/scripts/derive-generated.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Derive the web copies of the user-provided GPT image batch (v2-batch-01). + + python3 -I scripts/derive-generated.py --from "/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01" + +Optional maintenance step (needs Pillow with WebP). The derived files in +public/generated/batch-01/ are committed, so a normal install never runs this. +What it does, and nothing else: + 1. checks every chosen original against the batch's own SHA256SUMS.json and manifest.json; + 2. writes resized copies (WebP / PNG) to public/generated/batch-01/ — no retouching, + no colour changes; the only edits are resizing and, for the favicon, making the + black corners outside the icon's rounded square transparent; + 3. records original + derivative hashes and the generation prompts in + docs/GENERATED_ASSETS.json (checked by `npm run assets:generated`). +Re-running with a different Pillow/libwebp may produce different bytes; the JSON +then simply records the new hashes. +Rights: user-provided GPT output; commercial use and redistribution are unverified. +""" +import argparse +import hashlib +import json +import os +import sys +from PIL import Image, ImageChops + +RIGHTS = '用户经 GPT 生成并提供;商用/再分发权未核实' +BATCH = 'v2-batch-01' +DROPBOX_FOLDER = '/Everwith_Claude_Frontend_Rebuild_2026-10-13/generated-assets/batch-01/' + +STAGE = 'LiveStage backdrop behind the Live2D canvas and the static portrait (character page, private chat, table rail, story rail, discover card)' +# slot, chosen variant (None = single image), derivative name, (w, h), format, quality, used in +SPEC = [ + ('story-last-train-cover-tall', 'a', 'story-last-train-cover-tall.webp', (768, 1152), 'webp', 82, 'Discover story card (4:5), story preparation poster (3:4), Recent / continue / play-together thumbnails'), + ('story-last-train-cover-wide', 'a', 'story-last-train-cover-wide.webp', (1280, 853), 'webp', 82, 'Story detail hero (4:3)'), + ('story-last-train-scene-waiting-room', None, 'story-last-train-scene-waiting-room.webp', (1280, 853), 'webp', 80, 'Story play scene banner: opening and beat 1'), + ('story-last-train-scene-corner', None, 'story-last-train-scene-corner.webp', (1280, 853), 'webp', 80, 'Story play scene banner: beat 2'), + ('story-last-train-scene-duty-room', None, 'story-last-train-scene-duty-room.webp', (1280, 853), 'webp', 80, 'Story play scene banner: beat 3'), + ('story-last-train-scene-platform-dawn', None, 'story-last-train-scene-platform-dawn.webp', (1280, 853), 'webp', 80, 'Story play scene banner: open mode after the last beat'), + ('story-promise-rain-cover-tall', 'a', 'story-promise-rain-cover-tall.webp', (768, 1152), 'webp', 82, 'Discover story card (4:5), story preparation poster (3:4), Recent / continue / play-together thumbnails'), + ('story-promise-rain-cover-wide', 'a', 'story-promise-rain-cover-wide.webp', (1280, 853), 'webp', 82, 'Story detail hero (4:3)'), + ('story-promise-rain-scene-entrance-rain', None, 'story-promise-rain-scene-entrance-rain.webp', (1280, 853), 'webp', 80, 'Story play scene banner: opening and beat 1'), + ('story-promise-rain-scene-backstage', None, 'story-promise-rain-scene-backstage.webp', (1280, 853), 'webp', 80, 'Story play scene banner: beat 2'), + ('story-promise-rain-scene-office', None, 'story-promise-rain-scene-office.webp', (1280, 853), 'webp', 80, 'Story play scene banner: beat 3'), + ('story-promise-rain-scene-entrance-after-rain', None, 'story-promise-rain-scene-entrance-after-rain.webp', (1280, 853), 'webp', 80, 'Story play scene banner: beat 4'), + ('backdrop-lena', 'a', 'backdrop-lena.webp', (768, 1152), 'webp', 78, STAGE), + ('backdrop-arthur', 'a', 'backdrop-arthur.webp', (768, 1152), 'webp', 78, STAGE), + ('backdrop-marcus', 'a', 'backdrop-marcus.webp', (768, 1152), 'webp', 78, STAGE), + ('backdrop-nora', 'a', 'backdrop-nora.webp', (768, 1152), 'webp', 78, STAGE), + ('backdrop-iris', 'a', 'backdrop-iris.webp', (768, 1152), 'webp', 78, STAGE), + ('backdrop-generic', None, 'backdrop-generic.webp', (768, 1152), 'webp', 78, 'Stage backdrop for characters without their own (user-created work characters)'), + ('game-royale-cover', 'a', 'game-royale-cover.webp', (768, 1152), 'webp', 82, 'Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails'), + ('game-splendor-cover', 'a', 'game-splendor-cover.webp', (768, 1152), 'webp', 82, 'Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails'), + ('game-cabo-cover', 'a', 'game-cabo-cover.webp', (768, 1152), 'webp', 82, 'Game poster: Discover game card (1:1), game preparation (3:4), play-together and Recent thumbnails'), + ('empty-friends', None, 'empty-friends.webp', (480, 480), 'webp', 86, 'Friends: no friends yet'), + ('empty-recent', None, 'empty-recent.webp', (480, 480), 'webp', 86, 'Recent: nothing yet / nothing archived'), + ('empty-create', None, 'empty-create.webp', (480, 480), 'webp', 86, 'Create: no works yet'), + ('state-not-found', None, 'state-not-found.webp', (480, 480), 'webp', 86, 'Not found (route, character, story, run, game, friend, work)'), + ('state-load-error', None, 'state-load-error.webp', (480, 480), 'webp', 86, 'Load failure (distinct from “nothing here”)'), + ('state-signin', None, 'state-signin.webp', (480, 480), 'webp', 86, 'Sign-in needed (friends, chat) and the sign-in dialog'), + ('create-type-story', None, 'create-type-story.webp', (320, 320), 'webp', 86, 'New work dialog: story type'), + ('create-type-character', None, 'create-type-character.webp', (320, 320), 'webp', 86, 'New work dialog: character type'), + *[(f'work-cover-0{i}', None, f'work-cover-0{i}.webp', (600, 900), 'webp', 80, 'Work cover choice: work cards, editor, preview panel') for i in range(1, 7)], + *[(f'persona-avatar-0{i}', None, f'persona-avatar-0{i}.webp', (256, 256), 'webp', 85, 'Persona avatar choice: account page, navigation, own seat at tables') for i in range(1, 9)], + ('brand-emblem', 'a', 'brand-emblem.png', (128, 128), 'png', None, 'Navigation brand mark and the first-load screen'), + ('app-icon', 'a', 'favicon-64.png', (64, 64), 'favicon', None, 'Browser tab icon (corners outside the rounded square made transparent)'), + ('app-icon', 'a', 'apple-touch-icon.png', (180, 180), 'touch', None, 'Home-screen icon (corners filled with the tile colour)'), +] + + +def sha256(path): + h = hashlib.sha256() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(1 << 20), b''): + h.update(chunk) + return h.hexdigest() + + +def resize(im, size): + if im.mode == 'RGBA': # resample with premultiplied alpha so edges keep their colour + return im.convert('RGBa').resize(size, Image.Resampling.LANCZOS).convert('RGBA') + return im.convert('RGB').resize(size, Image.Resampling.LANCZOS) + + +def corner_alpha(im): + """Icon tile spans the full canvas; only the four corners outside its rounded square + are pure black. Alpha there follows brightness; elsewhere stays opaque.""" + rgb = im.convert('RGB') + w, h = rgb.size + zone = max(8, round(w * 0.14)) + r, g, b = rgb.split() + brightest = ImageChops.lighter(ImageChops.lighter(r, g), b) # tile edge is dark brown (max channel ≈ 34), corners are 0 + lum = brightest.point(lambda v: 255 if v >= 28 else round(v * 255 / 28)) + alpha = Image.new('L', (w, h), 255) + for box in [(0, 0, zone, zone), (w - zone, 0, w, zone), (0, h - zone, zone, h), (w - zone, h - zone, w, h)]: + alpha.paste(lum.crop(box), box) + out = rgb.copy() + out.putalpha(alpha) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--from', dest='src', required=True, help='folder with the batch PNGs, manifest.json and SHA256SUMS.json') + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ap.add_argument('--out', default=os.path.join(root, 'public', 'generated', 'batch-01')) + ap.add_argument('--doc', default=os.path.join(root, 'docs', 'GENERATED_ASSETS.json')) + args = ap.parse_args() + + sums = json.load(open(os.path.join(args.src, 'SHA256SUMS.json'), encoding='utf-8')) + manifest = json.load(open(os.path.join(args.src, 'manifest.json'), encoding='utf-8')) + records = {r['filename']: r for r in manifest['records']} + os.makedirs(args.out, exist_ok=True) + + assets, problems = [], [] + for slot, variant, name, size, fmt, quality, used in SPEC: + original = f'{slot}__{variant}.png' if variant else f'{slot}.png' + path = os.path.join(args.src, original) + rec = records.get(original) + if not os.path.exists(path) or rec is None: + problems.append(f'missing original {original}') + continue + digest = sha256(path) + if digest != sums.get(original) or digest != rec.get('sha256'): + problems.append(f'hash mismatch {original}') + continue + im = Image.open(path) + im.load() + dest = os.path.join(args.out, name) + if fmt == 'webp': + out = resize(im, size) + out.save(dest, 'WEBP', quality=quality, method=6, **({'exact': False} if out.mode == 'RGBA' else {})) + transform = f'Lanczos resize {im.size[0]}x{im.size[1]} -> {size[0]}x{size[1]}{" (premultiplied alpha)" if out.mode == "RGBA" else ""}; WebP q{quality} method 6' + elif fmt == 'png': + out = resize(im, size) + out.save(dest, 'PNG', optimize=True) + transform = f'Lanczos resize {im.size[0]}x{im.size[1]} -> {size[0]}x{size[1]} (premultiplied alpha); PNG' + elif fmt == 'favicon': + out = resize(corner_alpha(im), size) + out.save(dest, 'PNG', optimize=True) + transform = f'black corners outside the rounded square -> transparent; Lanczos resize -> {size[0]}x{size[1]} (premultiplied alpha); PNG' + else: # touch icon: opaque square, corners filled with the tile's own edge colour + tile = Image.new('RGB', im.size, im.convert('RGB').getpixel((2, im.size[1] // 2))) + tile.paste(corner_alpha(im), (0, 0), corner_alpha(im)) + out = tile.resize(size, Image.Resampling.LANCZOS) + out.save(dest, 'PNG', optimize=True) + transform = f'corners outside the rounded square filled with the tile edge colour; Lanczos resize -> {size[0]}x{size[1]}; PNG' + others = sorted(f for f in records if f.startswith(f'{slot}__') and f != original) + assets.append({ + 'slot': slot, 'chosen_variant': variant, 'used_in': used, + 'original': {'file': original, 'dropbox_path': DROPBOX_FOLDER + original, 'sha256': digest, 'bytes': os.path.getsize(path), + 'dims': list(im.size), 'mode': im.mode, 'generated_at_utc': rec.get('generated_at_utc'), 'tool': rec.get('tool'), + 'model': rec.get('model'), 'source': rec.get('source'), 'qa_status': rec.get('qa_status'), + 'reference_images': rec.get('reference_images') or [], 'prompt': rec.get('prompt'), 'actual_prompt': rec.get('actual_prompt')}, + 'derivative': {'path': f'public/generated/batch-01/{name}', 'url': f'/generated/batch-01/{name}', 'sha256': sha256(dest), + 'bytes': os.path.getsize(dest), 'dims': list(out.size), 'mode': out.mode, 'transform': transform}, + 'unused_candidates': others, + }) + if problems: + print('\n'.join(problems), file=sys.stderr) + sys.exit(1) + doc = { + 'schema': 'everwith-generated-assets-v1', 'batch': BATCH, 'rights': RIGHTS, + 'note': 'User-provided GPT output. Not part of the original handoff package; commercial use and redistribution are unverified. Only resized here (see transform).', + 'source': {'dropbox_folder': DROPBOX_FOLDER, 'manifest_sha256': sha256(os.path.join(args.src, 'manifest.json')), + 'sha256sums_sha256': sha256(os.path.join(args.src, 'SHA256SUMS.json')), 'slots': manifest.get('requested_slots'), 'images': manifest.get('requested_images')}, + 'derived_with': {'pillow': Image.__version__}, + 'assets': assets, + } + with open(args.doc, 'w', encoding='utf-8') as f: + json.dump(doc, f, ensure_ascii=False, indent=1) + f.write('\n') + total = sum(a['derivative']['bytes'] for a in assets) + print(json.dumps({'derivatives': len(assets), 'bytes': total, 'out': os.path.relpath(args.out, root)})) + + +if __name__ == '__main__': + main() diff --git a/everwith-claude-frontend/scripts/sync-assets.mjs b/everwith-claude-frontend/scripts/sync-assets.mjs new file mode 100644 index 00000000..7d3d32f5 --- /dev/null +++ b/everwith-claude-frontend/scripts/sync-assets.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// Copies the selected, unmodified handoff files into this project and verifies +// every byte against the package's own verification/SHA256SUMS.txt. +// +// npm run assets:sync -- --from /path/to/Everwith_Claude_Frontend_Handoff_2026-10-13 +// npm run assets:check (re-hash what was copied; no copying) +// +// Nothing is downloaded, generated or re-encoded. Missing files are recorded +// and the app degrades to static images / text / explicit "unavailable". +import {createHash} from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, copyFileSync} from 'node:fs'; +import {dirname, join, relative, resolve, sep} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const checkOnly = args.includes('--check'); +const fromIndex = args.indexOf('--from'); +const from = fromIndex >= 0 ? args[fromIndex + 1] : process.env.EVERWITH_HANDOFF_DIR; +const PUBLIC_ROOT = join(root, 'public', 'handoff'); +const VENDOR_ROOT = join(root, 'src', 'vendor', 'cubism-framework'); +const LOCK = join(PUBLIC_ROOT, 'ASSET_LOCK.json'); + +/** Destination policy: browser-served files go to public/handoff/; + * Cubism Framework TypeScript goes to src/vendor (compiled by Vite, never served raw). */ +function destinationFor(rel) { + if (rel.startsWith('assets/runtime/cubism/Framework/src/')) return join(VENDOR_ROOT, 'src', rel.slice('assets/runtime/cubism/Framework/src/'.length)); + if (rel === 'assets/runtime/cubism/Framework/LICENSE.md') return join(VENDOR_ROOT, 'LICENSE.md'); + return join(PUBLIC_ROOT, rel); +} + +// Selected subset. Prefix entries (ending in '/') take every listed file below them. +const SELECTION = [ + // Cubism runtime subset (Core, Framework source, WebGL shaders, original notices) + 'assets/runtime/cubism/', + // Five frozen Live2D runtimes with every file their model3 references + 'assets/live2d/', + // Discovery / portrait / scene imagery and existing home preview clips + 'assets/companions/feed/', + 'assets/companions/portraits/', + 'assets/companions/scenes/', + 'assets/companions/game-previews/v1/', + // Royale: casino scene atlas used by the frozen board, banknote ornament, provenance + 'assets/games/royale/generated/casino-scene-atlas-r7.png', + 'assets/games/royale/generated/fictional-game-token-ornament-20261004.png', + 'assets/games/royale/generated/PROVENANCE.json', + // Splendor: active art-catalog indices (6,7,11..19), tier backs, gem finishes, salon + 'assets/games/splendor/nobles-a.jpg', + 'assets/games/splendor/nobles-b.jpg', + 'assets/games/splendor/renaissance-tier1-20261007.png', + 'assets/games/splendor/renaissance-tier2-20261007.png', + 'assets/games/splendor/renaissance-tier3-20261007.png', + 'assets/games/splendor/renaissance-tier2-warehouse-round3-20261007.png', + 'assets/games/splendor/renaissance-tier2-ledger-round3-20261007.png', + 'assets/games/splendor/renaissance-tier2-caravan-round3-20261007.png', + 'assets/games/splendor/renaissance-tier3-cathedral-round4-20261007.png', + 'assets/games/splendor/renaissance-tier3-harbor-round4-20261007.png', + 'assets/games/splendor/teal-velour-calm-round4-20261007.png', + 'assets/games/splendor/tier-back-1-20261005.png', + 'assets/games/splendor/tier-back-2-20261005.png', + 'assets/games/splendor/tier-back-3-20261005.png', + 'assets/games/splendor/tier-backs-provenance-20261005.json', + 'assets/games/splendor/gem-diamond-finish-round4-20261007.png', + 'assets/games/splendor/gem-sapphire-finish-round4-20261007.png', + 'assets/games/splendor/gem-gold-finish-round4-20261007.png', + 'assets/games/splendor/gem-emerald-polish-round4-20261007.png', + 'assets/games/splendor/gem-ruby-polish-round4-20261007.png', + 'assets/games/splendor/gem-onyx-polish-round4-20261007.png', + 'assets/games/splendor/backgrounds/salon.png', + // CABO: default illustration-print/1 catalogue (manifest.json) + zones + felt + 'assets/games/cabo/card-rank-art/manifest.json', + 'assets/games/cabo/card-rank-art/illustration-print-1-zones.json', + 'assets/games/cabo/embedded-surface-1.webp', + 'assets/games/cabo/card-illustration/provenance.json', + // Frozen pins used at runtime for identity checks + 'verification/MODEL_PINS.json', +]; + +function sha256(file) { return createHash('sha256').update(readFileSync(file)).digest('hex'); } +function walk(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) out.push(...walk(full)); else out.push(full); + } + return out; +} +function rightsIndex(base) { + const file = join(base, 'verification', 'ASSET_MANIFEST.json'); + if (!existsSync(file)) return new Map(); + const rows = JSON.parse(readFileSync(file, 'utf8')); + return new Map(rows.map(row => [row.path, {rights: row.rights, status: row.status, source_commit: row.source_commit}])); +} + +if (checkOnly) { + if (!existsSync(LOCK)) { console.error('No ASSET_LOCK.json. Run: npm run assets:sync -- --from '); process.exit(2); } + const lock = JSON.parse(readFileSync(LOCK, 'utf8')); + let bad = 0; + for (const item of lock.files) { + const dest = join(root, item.dest); + if (!existsSync(dest)) { console.error(`MISSING ${item.dest}`); bad++; continue; } + if (sha256(dest) !== item.sha256) { console.error(`HASH MISMATCH ${item.dest}`); bad++; } + } + console.log(JSON.stringify({checked: lock.files.length, problems: bad, missingAtSync: lock.missing.length}, null, 2)); + process.exit(bad ? 1 : 0); +} + +if (!from) { + console.error('Usage: npm run assets:sync -- --from /path/to/Everwith_Claude_Frontend_Handoff_2026-10-13'); + process.exit(2); +} +const base = resolve(from); +const sums = join(base, 'verification', 'SHA256SUMS.txt'); +if (!existsSync(sums)) { console.error(`Not a handoff package (missing verification/SHA256SUMS.txt): ${base}`); process.exit(2); } +const expected = new Map(readFileSync(sums, 'utf8').split('\n').filter(Boolean).map(line => { + const [hash, ...rest] = line.split(' '); + return [rest.join(' ').trim(), hash.trim()]; +})); +const rights = rightsIndex(base); + +const wanted = new Set(); +// CABO rank art: exactly the 14 ranks + back named by the default catalogue. +const caboManifest = join(base, 'assets', 'games', 'cabo', 'card-rank-art', 'manifest.json'); +if (existsSync(caboManifest)) { + const m = JSON.parse(readFileSync(caboManifest, 'utf8')); + for (const entry of [...(m.ranks ?? []), m.back].filter(Boolean)) { + const name = entry.asset?.path?.split('/').pop(); + if (name) wanted.add(`assets/games/cabo/card-rank-art/${name}`); + } +} +for (const entry of SELECTION) { + if (entry.endsWith('/')) { + for (const rel of expected.keys()) if (rel.startsWith(entry)) wanted.add(rel); + // Files present on disk but absent from the sums list are not trusted. + } else wanted.add(entry); +} + +const files = [], missing = [], mismatched = []; +for (const rel of [...wanted].sort()) { + const source = join(base, ...rel.split('/')); + const want = expected.get(rel); + if (!want) { missing.push({rel, reason: 'not listed in SHA256SUMS'}); continue; } + if (!existsSync(source)) { missing.push({rel, reason: 'absent in handoff copy'}); continue; } + const got = sha256(source); + if (got !== want) { mismatched.push(rel); continue; } + const dest = destinationFor(rel); + mkdirSync(dirname(dest), {recursive: true}); + copyFileSync(source, dest); + if (sha256(dest) !== want) { mismatched.push(rel); continue; } + const r = rights.get(rel); + files.push({rel, dest: relative(root, dest).split(sep).join('/'), bytes: statSync(dest).size, sha256: want, + rights: r?.rights ?? (rel.startsWith('verification/') ? 'handoff verification metadata' : 'see handoff 08_ASSETS_AND_RIGHTS.md'), + status: r?.status ?? null}); +} + +mkdirSync(PUBLIC_ROOT, {recursive: true}); +const lock = { + schema: 'everwith-claude-asset-lock-v1', + source_package: 'Everwith_Claude_Frontend_Handoff_2026-10-13', + copied_at_utc: new Date().toISOString(), + policy: 'byte-identical copies verified against the package SHA256SUMS; no resize/re-encode; private user-authorized handoff; commercial/redistribution rights unverified', + files, missing, mismatched, +}; +writeFileSync(LOCK, JSON.stringify(lock, null, 2)); +// Small browser-side availability index (paths only, no hashes needed at runtime). +writeFileSync(join(PUBLIC_ROOT, 'available.json'), JSON.stringify({files: files.filter(f => f.dest.startsWith('public/')).map(f => f.rel)}, null, 0)); +console.log(JSON.stringify({copied: files.length, bytes: files.reduce((n, f) => n + f.bytes, 0), missing: missing.length, mismatched: mismatched.length}, null, 2)); +if (missing.length) console.log('Missing (app will degrade):', missing.map(m => m.rel).join(', ')); +if (mismatched.length) { console.error('Hash mismatch, not copied:', mismatched.join(', ')); process.exit(1); } diff --git a/everwith-claude-frontend/src/api/adapter.ts b/everwith-claude-frontend/src/api/adapter.ts new file mode 100644 index 00000000..c6f5f76b --- /dev/null +++ b/everwith-claude-frontend/src/api/adapter.ts @@ -0,0 +1,29 @@ +/** + * Adapter seam. Default: in-process LOCAL MOCK (no network). `VITE_EVERWITH_ADAPTER=real` + * selects network transport, and even then nothing is requested unless + * `VITE_EVERWITH_ALLOW_NETWORK=1` is also set — there is no silent real→mock fallback. + */ +import {createClient} from './client' +import type {Fetcher} from './client' +import {mockFetch} from '../mock/server' +import {CompanionRequestError} from '../contracts/companion' + +export const ADAPTER_MODE: 'mock' | 'real' = import.meta.env.VITE_EVERWITH_ADAPTER === 'real' ? 'real' : 'mock' + +/** Concrete gaps before a real adapter can be enabled (also listed in README). */ +export const REAL_ADAPTER_GAPS: {area: string; gap: string}[] = [ + {area: 'account', gap: 'Real /account shape has no `guest_id`/`mode`; guest-merge endpoint and CSRF bootstrap (/request-protection) must be confirmed against the Pydantic contract.'}, + {area: 'catalog', gap: '/discover (paged, typed search), /stories, /games and /characters/{id}?version are candidates; only /characters is in ENDPOINTS.json.'}, + {area: 'friends', gap: 'rename/reset/remove commands and `origin` (story F2 / game F7 binding) are candidates; server must bind source version + epoch atomically.'}, + {area: 'private chat', gap: 'POST …/operations must return {operation, admission}; GET …/operations/{id}/receipt and …/public-stream?run_epoch are candidates from the stream design, not verified endpoints.'}, + {area: 'stories', gap: 'All /story-sessions endpoints are new candidates; real narrator (Luna) and atomic turn validation are not connected.'}, + {area: 'games', gap: '/game-sessions act/advance/hold/abandon/rematch shapes are candidates; JEV decision seam is not connected (fixed-strategy NPC only).'}, + {area: 'creation', gap: '/works, /previews and assist endpoints are candidates; no AI model is called.'}, + {area: 'voice', gap: 'No TTS/viseme service; lip-sync only follows the local demo audio track.'}, +] + +const blockedFetch: Fetcher = async () => { throw new CompanionRequestError('real_adapter_not_enabled') } +const networkAllowed = import.meta.env.VITE_EVERWITH_ALLOW_NETWORK === '1' +export const api = ADAPTER_MODE === 'real' + ? createClient(networkAllowed ? window.fetch.bind(window) : blockedFetch, import.meta.env.VITE_EVERWITH_API_BASE ?? '') + : createClient(mockFetch) diff --git a/everwith-claude-frontend/src/api/client.ts b/everwith-claude-frontend/src/api/client.ts new file mode 100644 index 00000000..10a2701b --- /dev/null +++ b/everwith-claude-frontend/src/api/client.ts @@ -0,0 +1,171 @@ +/** + * One typed client over a fetch-compatible transport. The mock adapter passes an + * in-process fetch; a real adapter would pass `window.fetch` with a base URL. All + * responses go through the ported contract parsers either way. + */ +import {CompanionRequestError, parseAccount, parseCharacter, parseCharacters, parseFriend, parseFriends} from '../contracts/companion' +import {parsePrivateOperation, parsePrivateSnapshot} from '../contracts/private-chat-client' +import type {PrivateSnapshot} from '../contracts/private-chat-client' +import {parsePublicAdmission} from '../contracts/public-events' +import type {PublicAdmission} from '../contracts/public-events' +import type {Lang} from '../lib/util' +import type {DiscoverPage, GameJSON, GameViewJSON, PreviewJSON, RecordJSON, StoryJSON, StorySessionJSON, TableMessageJSON, WorkJSON} from './types' + +export type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function assertGameView(value: unknown): GameViewJSON { + const v = value as GameViewJSON + if (!v || v.schema_version !== 'everwith-game-view-v1' || !Array.isArray(v.seats) || typeof v.revision !== 'number') throw new CompanionRequestError('game_response_invalid') + const pv = v.player_view as Record + // Defence in depth: a player projection must never carry authority-only fields. + for (const key of ['truth', 'rng', 'up', 'seed', 'lastAction']) if (pv && key in pv) throw new CompanionRequestError('game_private_view_invalid') + if (v.game_id === 'cabo' && (pv.observer !== 0 || (pv.deck as unknown[]).some(x => x !== null))) throw new CompanionRequestError('game_private_view_invalid') + if (v.game_id === 'gems' && Object.values(pv.decks as Record).some(x => typeof x !== 'number')) throw new CompanionRequestError('game_private_view_invalid') + if (v.game_id === 'vegas' && 'deck' in pv) throw new CompanionRequestError('game_private_view_invalid') + if (v.seats[0]?.kind !== 'player' || v.seats.slice(1).some(s => s.kind === 'player') || new Set(v.seats.map(s => s.identity)).size !== v.seats.length) throw new CompanionRequestError('game_identity_invalid') + return v +} +function assertStory(value: unknown): StorySessionJSON { + const v = value as StorySessionJSON + if (!v || v.schema_version !== 'everwith-story-session-v1' || !Array.isArray(v.turns) || !Array.isArray(v.cast)) throw new CompanionRequestError('invalid_response') + if (v.cast.filter(c => c.friend_id).length !== new Set(v.cast.filter(c => c.friend_id).map(c => c.friend_id)).size) throw new CompanionRequestError('invalid_response') + return v +} + +export function createClient(fetcher: Fetcher, base = '') { + async function request(path: string, body?: unknown, method?: string, signal?: AbortSignal): Promise { + let response: Response + try { + response = await fetcher(`${base}/api/companions${path}`, { + method: method ?? (body === undefined ? 'GET' : 'POST'), credentials: 'include', cache: 'no-store', signal, + headers: body === undefined ? {} : {'Content-Type': 'application/json', 'X-Tiny-Stories-Request': '1'}, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') throw error + throw new CompanionRequestError('network_error') + } + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + const raw = data && typeof data === 'object' && 'error' in data ? (data as {error: {code?: unknown; detail?: unknown}}).error : null + const code = raw && typeof raw.code === 'string' && /^[a-z_]{1,64}$/.test(raw.code) ? raw.code : 'unavailable' + throw new CompanionRequestError(code, response.status, raw && raw.detail && typeof raw.detail === 'object' ? raw.detail as Record : null) + } + return data as T + } + const q = (params: Record) => { + const s = new URLSearchParams() + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== '') s.set(k, String(v)) + const t = s.toString(); return t ? `?${t}` : '' + } + type Command = {key: string; text: string; language: Lang; presented_turn_id: string; expected_revision: number} + const parseStart = (raw: unknown) => { + const row = raw as {operation: unknown; admission: unknown} + return {operation: parsePrivateOperation(row.operation), admission: parsePublicAdmission(row.admission)} + } + return { + request, + account: { + get: async () => parseAccount(await request('/account')), + login: async (email: string, password: string) => parseAccount(await request('/account/login', {email, password})), + register: async (email: string, password: string) => parseAccount(await request('/account/register', {email, password})), + logout: async () => parseAccount(await request('/account/logout', {})), + /** avatar: undefined keeps the current picture, null removes it. */ + persona: async (name: string, bio: string, avatar?: string | null) => parseAccount(await request('/account/persona', avatar === undefined ? {name, bio} : {name, bio, avatar}, 'PUT')), + }, + catalog: { + discover: (params: {type: string; q: string; offset: number; limit?: number}, signal?: AbortSignal) => request(`/discover${q({type: params.type, q: params.q, offset: params.offset, limit: params.limit ?? 8})}`, undefined, undefined, signal), + characters: async () => parseCharacters(await request('/characters')), + character: async (id: string, version: string) => parseCharacter(await request(`/characters/${encodeURIComponent(id)}${q({version})}`)), + story: (id: string, version?: string) => request(`/stories/${encodeURIComponent(id)}${q({version})}`), + stories: () => request<{items: StoryJSON[]}>('/stories'), + games: () => request<{items: GameJSON[]}>('/games'), + }, + friends: { + list: async (accountId: string, query = '') => parseFriends(await request(`/friends${q({limit: 50, offset: 0, q: query, expected_account_id: accountId})}`), accountId), + get: async (id: string) => parseFriend(await request(`/friends/${encodeURIComponent(id)}`)), + add: async (input: {key: string; source_id: string; source_version: string; name: string; expected_account_id: string; origin?: Record}) => parseFriend(await request('/friends', input)), + rename: async (id: string, epoch: number, name: string, accountId: string) => request<{friend: unknown}>(`/friends/${encodeURIComponent(id)}/rename`, {expected_epoch: epoch, name, expected_account_id: accountId}).then(r => parseFriend(r.friend)), + reset: async (id: string, epoch: number, accountId: string) => request<{friend: unknown}>(`/friends/${encodeURIComponent(id)}/reset`, {expected_epoch: epoch, expected_account_id: accountId}).then(r => parseFriend(r.friend)), + remove: (id: string, epoch: number, accountId: string) => request(`/friends/${encodeURIComponent(id)}/remove`, {expected_epoch: epoch, expected_account_id: accountId}), + conversations: (id: string) => request<{items: {id: string; friend_epoch: number; status: string; turns: number; updated_at: string}[]}>(`/friends/${encodeURIComponent(id)}/conversations`), + }, + chat: { + open: async (friendId: string, epoch: number, accountId: string, key: string, language: Lang) => parsePrivateSnapshot(await request(`/friends/${encodeURIComponent(friendId)}/conversation`, {key, expected_account_id: accountId, expected_friend_epoch: epoch, language}), friendId, epoch), + read: async (conversationId: string, friendId: string, epoch: number): Promise => { + const snapshot = parsePrivateSnapshot(await request(`/conversations/${encodeURIComponent(conversationId)}`), friendId, epoch) + if (snapshot.conversation.id !== conversationId) throw new CompanionRequestError('invalid_response') + return snapshot + }, + send: async (conversationId: string, command: Command, accountId: string, epoch: number, signal?: AbortSignal) => + parseStart(await request(`/conversations/${encodeURIComponent(conversationId)}/operations`, {...command, expected_account_id: accountId, expected_friend_epoch: epoch}, 'POST', signal)), + stream: async (admission: PublicAdmission, signal: AbortSignal) => { + try { return await fetcher(`${base}/api/companions/operations/${encodeURIComponent(admission.operation_id)}/public-stream?run_epoch=${admission.run_epoch}`, {credentials: 'include', cache: 'no-store', signal}) } + catch (error) { if (error instanceof DOMException && error.name === 'AbortError') throw error; throw new CompanionRequestError('network_error') } + }, + receipt: async (operationId: string) => { + const row = await request<{owner_id: string; operation: unknown; snapshot: unknown; admission: unknown}>(`/operations/${encodeURIComponent(operationId)}/receipt`) + return {owner_id: row.owner_id, operation: row.operation, snapshot: row.snapshot, admission: row.admission ? parsePublicAdmission(row.admission) : null} + }, + lookup: async (conversationId: string, key: string) => { + const row = await request<{operation: unknown; admission: unknown; text: string}>(`/conversations/${encodeURIComponent(conversationId)}/operations/lookup${q({key})}`) + return {operation: parsePrivateOperation(row.operation), admission: row.admission ? parsePublicAdmission(row.admission) : null, text: row.text} + }, + retry: async (operationId: string, accountId: string, epoch: number) => parseStart(await request(`/operations/${encodeURIComponent(operationId)}/retry`, {expected_account_id: accountId, expected_friend_epoch: epoch})), + cancel: async (operationId: string, accountId: string, epoch: number) => parsePrivateOperation(await request(`/operations/${encodeURIComponent(operationId)}/cancel`, {expected_account_id: accountId, expected_friend_epoch: epoch})), + rollback: async (conversationId: string, turnId: string, revision: number, friendId: string, epoch: number, language: Lang) => { + const row = await request<{snapshot: unknown; restored_draft: string}>(`/conversations/${encodeURIComponent(conversationId)}/rollback`, {turn_id: turnId, expected_revision: revision, language}) + return {snapshot: parsePrivateSnapshot(row.snapshot, friendId, epoch), restored: row.restored_draft} + }, + correction: async (conversationId: string, key: string, text: string, turnId: string | null, friendId: string, epoch: number, language: Lang) => { + const row = await request<{snapshot: unknown}>(`/conversations/${encodeURIComponent(conversationId)}/corrections`, {key, text, turn_id: turnId, language}) + return parsePrivateSnapshot(row.snapshot, friendId, epoch) + }, + }, + stories: { + start: async (body: {key: string; story_id: string; version: string; cast: {role_id: string; friend_id: string; friend_epoch: number}[]; language: Lang; expected_account_id: string}) => assertStory(await request('/story-sessions', body)), + get: async (id: string) => assertStory(await request(`/story-sessions/${encodeURIComponent(id)}`)), + turn: async (id: string, body: {key: string; kind: 'input' | 'continue'; text?: string; language: Lang; expected_revision: number}) => { + const row = await request<{operation: {operation_id: string; status: string}; session: unknown}>(`/story-sessions/${encodeURIComponent(id)}/turns`, body) + return {operation: row.operation, session: assertStory(row.session)} + }, + control: async (id: string, op: string, action: 'cancel' | 'retry') => { + const row = await request<{operation: {operation_id: string; status: string}; session: unknown; restored_input?: string | null}>(`/story-sessions/${encodeURIComponent(id)}/operations/${encodeURIComponent(op)}/${action}`, {}) + return {operation: row.operation, session: assertStory(row.session), restored: row.restored_input ?? null} + }, + rollback: async (id: string, turnId: string, revision: number) => { + const row = await request<{session: unknown; restored_draft: string}>(`/story-sessions/${encodeURIComponent(id)}/rollback`, {turn_id: turnId, expected_revision: revision}) + return {session: assertStory(row.session), restored: row.restored_draft} + }, + }, + games: { + start: async (body: Record) => assertGameView(await request('/game-sessions', body)), + lookup: async (key: string) => assertGameView(await request(`/game-sessions/lookup${q({key})}`)), + get: async (id: string) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}`)), + list: async () => (await request<{items: unknown[]}>('/game-sessions')).items.map(assertGameView), + act: async (id: string, key: string, action: unknown, revision: number) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}/act`, {key, action, expected_revision: revision})), + advance: async (id: string, key: string, revision: number, language: Lang) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}/advance`, {key, expected_revision: revision, language})), + hold: async (id: string, hold: boolean) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}/hold`, {hold})), + abandon: async (id: string, revision: number) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}/abandon`, {expected_revision: revision})), + rematch: async (id: string, key: string) => assertGameView(await request(`/game-sessions/${encodeURIComponent(id)}/rematch`, {key})), + messages: (id: string) => request<{items: TableMessageJSON[]}>(`/game-sessions/${encodeURIComponent(id)}/table-chat`), + say: (id: string, key: string, text: string, language: Lang) => request<{items: TableMessageJSON[]; reply_failed: boolean}>(`/game-sessions/${encodeURIComponent(id)}/table-chat`, {key, text, language}), + }, + records: { + list: (archived: boolean) => request<{items: RecordJSON[]}>(`/records${q({archived: archived ? 1 : 0})}`), + mutate: (kind: 'story' | 'game', id: string, action: 'archive' | 'restore' | 'delete') => request(`/records/${kind}/${encodeURIComponent(id)}/${action}`, {}), + }, + works: { + list: () => request<{items: WorkJSON[]}>('/works'), + create: (key: string, type: 'story' | 'character', name: string) => request('/works', {key, type, name}), + get: (id: string) => request(`/works/${encodeURIComponent(id)}`), + putDraft: (id: string, draft: Record, base: number) => request(`/works/${encodeURIComponent(id)}/draft`, {draft, base_revision: base}, 'PUT'), + save: (id: string, draftRevision: number) => request(`/works/${encodeURIComponent(id)}/save`, {draft_revision: draftRevision}), + share: (id: string, enabled: boolean, version: number | null) => request(`/works/${encodeURIComponent(id)}/share`, {enabled, version}), + assist: (id: string, instruction: string, draft: Record, language: Lang) => request<{draft: Record; summary: string[]; mode: string}>(`/works/${encodeURIComponent(id)}/assist`, {instruction, draft, language}), + preview: (workId: string, draftRevision: number, language: Lang) => request('/previews', {work_id: workId, draft_revision: draftRevision, language}), + previewTurn: (id: string, key: string, text: string, language: Lang) => request(`/previews/${encodeURIComponent(id)}/turns`, {key, text, language}), + }, + } +} +export type EverwithClient = ReturnType diff --git a/everwith-claude-frontend/src/api/types.ts b/everwith-claude-frontend/src/api/types.ts new file mode 100644 index 00000000..97368b0a --- /dev/null +++ b/everwith-claude-frontend/src/api/types.ts @@ -0,0 +1,46 @@ +import type {L10n, Lang} from '../lib/util' + +export type DiscoverItem = + | ({kind: 'character'} & CharacterJSON) + | ({kind: 'story'} & StoryJSON) + | ({kind: 'game'} & GameJSON) +export type CharacterJSON = {id: string; version: string; name: string; avatar: string | null; feed: string | null; description: L10n; tagline: L10n; traits: L10n[]; origin: 'official' | 'mine'; voice: string; live2d: Live2DKey | null} +export type Live2DKey = 'arthur' | 'lena' | 'marcus' | 'nora' | 'iris' +export type StoryRoleJSON = {id: string; title: L10n; description: L10n; replaceable: boolean; locked: boolean; default_character: CharacterJSON} +export type StoryJSON = {id: string; version: string; title: L10n; description: L10n; genre: L10n; content_note: L10n; image: string | null; image_wide: string | null; player_role: L10n; player_entry: 'script'; objectives: L10n[]; roles: StoryRoleJSON[]} +export type GameJSON = {id: 'vegas' | 'gems' | 'cabo'; slug: 'royale' | 'splendor' | 'cabo'; name: string; description: L10n; players: number[]; default_players: number; profile: string; profile_label: L10n; poster: string; preview: string; rule_notes: L10n[]} +export type DiscoverPage = {items: DiscoverItem[]; total: number; next_offset: number | null} + +export type StoryCastJSON = {role_id: string; role_title: L10n; kind: 'npc' | 'friend'; character_id: string; character_name: string; friend_id: string | null; friend_epoch: number | null; name: string; avatar: string | null; live2d: Live2DKey | null; replaceable: boolean} +export type StorySegmentJSON = {segment_id: string; kind: 'narration' | 'dialogue' | 'action'; role_id: string | null; text: string} +export type StoryTurnJSON = {turn_id: string; kind: 'opening' | 'input' | 'continue'; input: string | null; input_availability: 'available' | 'withdrawn'; language: Lang; created_at: string; delta: {minutes: number; clue: L10n | null; objective: number | null}; segments: StorySegmentJSON[]} +export type StorySessionJSON = { + schema_version: 'everwith-story-session-v1'; narration_mode: 'local_scripted_mock'; id: string; story_id: string; version: string; title: L10n; player_role: L10n + status: 'active' | 'read_only'; archived: boolean; revision: number; head_turn_id: string | null; created_at: string; updated_at: string; language: Lang + cast: StoryCastJSON[]; turns: StoryTurnJSON[] + state: {objectives: {text: L10n; done: boolean; current: boolean}[]; clues: {id: string; label: L10n}[]; minutes: number; scene: string; scene_image: string | null; scene_position: string | null; scene_label: L10n | null; present: string[]; hint: L10n | null; open_mode: boolean} + pending: {operation_id: string; kind: 'input' | 'continue'; text: string | null; status: string} | null +} + +export type SeatJSON = {seat: number; kind: 'player' | 'friend' | 'temporary'; identity: string; friend_epoch: number | null; name: string; source_id: string | null; source_version: string | null; avatar: string | null; live2d: Live2DKey | null} +export type GameViewJSON = { + schema_version: 'everwith-game-view-v1'; mode: 'rule_simulated_companions'; id: string; game_id: 'vegas' | 'gems' | 'cabo'; profile: string + seats: SeatJSON[]; lifecycle: 'active' | 'paused' | 'ended' | 'abandoned'; revision: number; ai_hold: boolean; actor_seat: number | 'any'; phase: string + player_view: unknown; settlement: null | {id: string; committed_revision: number; scores: number[]; winners: number[]; labels: L10n[]} + origin: {kind: 'discover' | 'friend' | 'private'; friend_id?: string; conversation_id?: string; return_hash: string} + created_at: string; updated_at: string; rematch_of: string | null; archived: boolean + log: {revision: number; seat: number; summary: L10n; kind: string}[]; expressions: Record; chat_count: number +} +export type TableMessageJSON = {id: string; seat: number; text: string; audience: 'table'; mode: 'player' | 'simulated_rule_reply'; kind?: 'dialogue' | 'narration'; language?: Lang} + +export type RecordJSON = { + kind: 'story' | 'game'; id: string; work_id: string; version: string; title: L10n; status: string; image: string | null + lineup: {role: L10n | null; name: string; friend: boolean; temporary?: boolean}[]; created_at: string; updated_at: string; archived: boolean + turns?: number; revision?: number; rematch_of?: string | null; result?: {scores: number[]; winners: number[]} | null +} +export type WorkJSON = { + id: string; type: 'story' | 'character'; name: string; created_at: string; updated_at: string; draft: Record; draft_revision: number + versions: {version: number; saved_at: string}[]; latest_version: number | null; dirty: boolean; errors: {field: string; code: string}[] + share: {enabled: boolean; version: number | null}; owner_kind: 'account' | 'guest'; saved_version?: number; unchanged?: boolean +} +export type PreviewJSON = {id: string; work_id: string; draft_revision: number; snapshot: Record; turns: {id: string; role: 'player' | 'character'; text: string; language: Lang}[]; created_at: string} diff --git a/everwith-claude-frontend/src/app/App.tsx b/everwith-claude-frontend/src/app/App.tsx new file mode 100644 index 00000000..1b289ed2 --- /dev/null +++ b/everwith-claude-frontend/src/app/App.tsx @@ -0,0 +1,139 @@ +import {useSyncExternalStore, lazy, Suspense, useEffect, useMemo, useState} from 'react' +import {Compass, History, PenLine, Users, UserCircle2, PanelLeftClose, PanelLeftOpen, Languages, FlaskConical} from 'lucide-react' +import {LangContext, makeT} from './i18n' +import {appStore, loadAccount, setLang, setNavCollapsed, useApp} from './store' +import {href, navigate, useRoute} from './router' +import type {Route} from './router' +import {AuthDialog} from '../features/account/AuthDialog' +import {MockPanel, OPEN_MOCK_PANEL} from '../features/mock/MockPanel' +import {scenarios} from '../mock/scenarios' +import {Toasts} from '../components/Toasts' +import {ADAPTER_MODE} from '../api/adapter' +import {Portrait, useAssetUrl} from '../components/Portrait' +import {BRAND_EMBLEM, personaAvatarUrl} from '../art/generated' +import {classNames} from '../lib/util' + +const DiscoverPage = lazy(() => import('../features/discover/DiscoverPage')) +const FriendsPage = lazy(() => import('../features/friends/FriendsPage')) +const ChatPage = lazy(() => import('../features/chat/ChatPage')) +const CharacterPage = lazy(() => import('../features/character/CharacterPage')) +const StoryDetailPage = lazy(() => import('../features/story/StoryDetailPage')) +const StoryPreparePage = lazy(() => import('../features/story/StoryPreparePage')) +const StoryPlayPage = lazy(() => import('../features/story/StoryPlayPage')) +const GamePreparePage = lazy(() => import('../features/games/GamePreparePage')) +const GamePlayPage = lazy(() => import('../features/games/GamePlayPage')) +const RecentPage = lazy(() => import('../features/recent/RecentPage')) +const CreatePage = lazy(() => import('../features/create/CreatePage')) +const WorkEditorPage = lazy(() => import('../features/create/WorkEditorPage')) +const AccountPage = lazy(() => import('../features/account/AccountPage')) +const NotFoundPage = lazy(() => import('../features/misc/NotFoundPage')) + +const NAV = [ + {key: 'friends', icon: Users, label: 'nav.friends', href: href.friends(), match: ['friends', 'chat']}, + {key: 'discover', icon: Compass, label: 'nav.discover', href: href.discover(), match: ['discover', 'character', 'story', 'storyPrepare', 'gamePrepare']}, + {key: 'recent', icon: History, label: 'nav.recent', href: href.recent(), match: ['recent', 'storyPlay']}, + {key: 'create', icon: PenLine, label: 'nav.create', href: href.create(), match: ['create', 'work']}, +] as const + +function page(route: Route) { + switch (route.name) { + case 'discover': return + case 'friends': return + case 'chat': return + case 'character': return + case 'story': return + case 'storyPrepare': return + case 'storyPlay': return + case 'gamePrepare': return + case 'gamePlay': return + case 'recent': return + case 'create': return + case 'work': return + case 'account': return + case 'notFound': return + default: return null + } +} + +export function App() { + const lang = useApp(s => s.lang) + const collapsed = useApp(s => s.navCollapsed) + const reducedMotion = useApp(s => s.reducedMotion) + const account = useApp(s => s.account) + const friends = useApp(s => s.friends) + const route = useRoute() + const scenarioCount = Object.keys(useSyncExternalStore(scenarios.subscribe, scenarios.snapshot, scenarios.snapshot)).length + const i18n = useMemo(() => ({lang, t: makeT(lang)}), [lang]) + const {t} = i18n + useEffect(() => { void loadAccount(); document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en' }, []) // eslint-disable-line react-hooks/exhaustive-deps + // Empty hash: returning users with friends land on Friends; new visitors on Discover. + // Explicit deep links, Back and sign-in returns always win over this default. + useEffect(() => { + if (route.name !== 'root' || !account) return + if (account.authenticated && friends === null) return + navigate(account.authenticated && friends && friends.length ? href.friends() : href.discover(), {replace: true}) + }, [route.name, account, friends]) + useEffect(() => { + const titles: Partial> = {discover: t('discover.title'), friends: t('friends.title'), recent: t('recent.title'), create: t('create.title'), account: t('account.title')} + document.title = `${titles[route.name] ?? 'Everwith'} · ${t('mock.badge')}` + }, [route.name, t]) + const task = route.name === 'gamePlay' + const active = NAV.find(n => (n.match as readonly string[]).includes(route.name))?.key + return ( + +
+ { e.preventDefault(); document.getElementById('main')?.focus() }}>{t('shell.skip')} + {!task && } +
+ {ADAPTER_MODE === 'real' &&
Real adapter selected but not connected. See README “Real adapter gaps”. No mock fallback is used.
} + {t('common.loading')}
}> + {page(route)} + + + + + + +
+ ) +} +/** Generated brand emblem; the lettermark stays as the fallback if the file is missing. */ +function BrandMark() { + const url = useAssetUrl(BRAND_EMBLEM) + const [failed, setFailed] = useState(false) + useEffect(() => setFailed(false), [url]) + return url && !failed + ? setFailed(true)} /> + : +} +export {appStore} diff --git a/everwith-claude-frontend/src/app/i18n.ts b/everwith-claude-frontend/src/app/i18n.ts new file mode 100644 index 00000000..61e15b9c --- /dev/null +++ b/everwith-claude-frontend/src/app/i18n.ts @@ -0,0 +1,275 @@ +import {createContext, useContext} from 'react' +import type {Lang} from '../lib/util' + +const zh = { + 'brand': 'Everwith', + 'mock.badge': '本地演示', + 'mock.badge.long': '本地演示 · 模拟账号与模拟回复 · 未接通真实服务', + 'mock.panel': '演示控制', + 'mock.panel.title': '本地演示 · 模拟场景', + 'mock.panel.desc': '这些开关只影响本浏览器中的本地 mock 服务,用于检查失败、竞争与降级状态。', + 'mock.panel.reset': '清空本地演示数据', + 'mock.panel.resetConfirm': '这会删除本浏览器里的模拟账号、好友、聊天、牌局与创作。确定吗?', + 'mock.panel.legacy': '导入 Splendor v1 旧存档样例', + 'mock.panel.once': '一次', + 'mock.panel.toggle': '持续', + 'nav.friends': '好友', 'nav.discover': '发现', 'nav.recent': '最近', 'nav.create': '创作', 'nav.account': '账号', + 'nav.collapse': '收起导航', 'nav.expand': '展开导航', 'nav.main': '主导航', + 'common.back': '返回', 'common.cancel': '取消', 'common.confirm': '确认', 'common.close': '关闭', 'common.retry': '重试', 'common.loading': '加载中…', + 'common.save': '保存', 'common.continue': '继续', 'common.more': '更多', 'common.done': '完成', 'common.edit': '编辑', 'common.delete': '删除', + 'common.archive': '归档', 'common.restore': '恢复', 'common.unavailable': '暂不可用', 'common.signin': '登录', 'common.send': '发送', 'common.stop': '停止', + 'common.optional': '可选', 'common.required': '必填', 'common.search': '搜索', 'common.clear': '清除', 'common.players': '人', + 'common.you': '你', 'common.friend': '好友', 'common.temporary': '临时同桌', 'common.npc': '默认人物', 'common.simulated': '模拟', + 'error.network': '连接中断。你的输入已保留,可以重试。', 'error.generic': '暂时无法完成。已保留的内容不会丢失,请稍后再试。', + 'error.auth': '需要重新登录。你的草稿与位置已保留。', 'error.forbidden': '你没有权限访问这项内容。', 'error.notFound': '找不到这项内容,可能已被删除或无权访问。', + 'error.conflict': '内容已在别处更新,已读取最新版本。', + 'discover.title': '发现', 'discover.subtitle': '找一个人,或者一件可以一起做的事。', + 'discover.tab.all': '全部', 'discover.tab.characters': '角色', 'discover.tab.stories': '世界(故事)', 'discover.tab.games': '游戏', + 'discover.search': '搜索角色、故事或游戏', 'discover.empty.search': '没有找到与“{q}”相关的内容。', 'discover.empty.clear': '清除搜索', + 'discover.empty.catalog': '这里暂时还没有内容。', 'discover.error': '内容加载失败(不是“没有内容”)。', 'discover.appendError': '加载更多失败,已加载的内容仍保留。', + 'discover.loadMore': '加载更多', 'discover.kind.character': '角色', 'discover.kind.story': '世界 · 故事', 'discover.kind.game': '游戏', + 'discover.players': '{n} 人', 'discover.companionGame': '可邀好友同桌', 'discover.continue': '继续上次', + 'friends.title': '好友', 'friends.subtitle': '长期好友会记得你们一起经历的事。', 'friends.empty': '你还没有好友。', 'friends.emptyHint': '可以先在发现里认识一位角色,或者从故事、桌游里把同伴加为好友。', + 'friends.guest': '登录后才能建立和保存好友。', 'friends.chat': '续聊', 'friends.play': '一起玩', 'friends.source': '来源:{name}', 'friends.since': '{date} 建立', + 'friends.number': '第 {n} 位', 'friends.search': '按名字或来源查找', + 'character.add': '加为好友', 'character.existing': '你已有的同源好友', 'character.existingHint': '同一个来源可以有多位独立好友,各自记得各自的经历。', + 'character.addAnother': '另建一位好友', 'character.chatWith': '和 {name} 续聊', 'character.public': '公开介绍', 'character.traits': '表达习惯', + 'character.noPrivate': '这里只展示作者公开的介绍,不包含任何人的私聊或经历。', 'character.voice': '预设声音(未接入语音服务)', + 'name.title': '为好友命名', 'name.desc': '名字是你对 Ta 的私人称呼;来源名“{source}”只用于区分。改名不会改变人格。', + 'name.label': '好友名字', 'name.confirm': '确认加为好友', 'name.required': '请填写名字。', 'name.creating': '正在建立好友…', + 'auth.title.login': '登录', 'auth.title.register': '注册', 'auth.email': '邮箱', 'auth.password': '密码', 'auth.passwordHint': '至少 8 位。本地演示不提供密码找回。', + 'auth.switchRegister': '没有账号?注册', 'auth.switchLogin': '已有账号?登录', 'auth.submit.login': '登录', 'auth.submit.register': '注册并登录', + 'auth.why': '登录后才能{action}。取消会回到原处,你的试玩与输入都会保留。', 'auth.mockNote': '这是本地模拟账号,只保存在这个浏览器里,不是真实认证服务。', + 'auth.error.invalid_credentials': '邮箱或密码不对。', 'auth.error.email_taken': '这个邮箱已注册,请直接登录。', 'auth.error.weak_password': '密码至少 8 位。', 'auth.error.invalid_email': '邮箱格式不对。', + 'auth.action.friend': '加好友', 'auth.action.save': '保存创作', 'auth.action.share': '分享作品', 'auth.action.chat': '私聊', 'auth.action.generic': '继续', + 'account.title': '账号与设置', 'account.guest': '游客(本地)', 'account.signedIn': '已登录:{email}', 'account.logout': '退出登录', 'account.persona': '我的 Persona', + 'account.personaName': '称呼', 'account.personaBio': '想让好友知道的事(可选)', 'account.personaSave': '保存 Persona', 'account.personaSaved': '已保存', + 'account.assets': '素材来源', 'account.assets.package': '交接包素材(角色立绘、动态形象、牌桌美术、预览短片):用户授权私人交接;商业/再分发权未核实。', + 'account.assets.generated': '生成素材(故事封面与场景、角色房间背景、游戏封面、空状态插画、Persona 头像、作品封面、图标):用户经 GPT 生成并提供;商用/再分发权未核实。仅用于这次私人演示。', + 'account.personaAvatar': '头像', 'account.avatarNone': '不用头像(显示首字)', 'account.avatarNote': '可选的物件小图,不是人像,不会和角色混淆。随 Persona 一起保存。', + 'account.language': '界面语言', 'account.languageNote': '切换立即生效;已有消息不会被翻译改写,新发送使用新语言。', 'account.noRecovery': '首期不提供密码找回(这不是遗漏的按钮)。', + 'account.motion': '减少动态效果', 'account.motionNote': '跟随系统设置;开启后人物只保留必要的表情与眨眼。', 'account.guestNote': '游客可以浏览和试玩;加好友、保存创作需要登录,登录后会安全归并你在本机的试玩进度。', + 'recent.title': '最近', 'recent.subtitle': '找回具体的某一局故事或桌游。', 'recent.empty': '还没有游玩记录。', 'recent.archived': '已归档', 'recent.active': '进行中与已结束', + 'recent.continue': '继续', 'recent.review': '回看', 'recent.lineup': '阵容', 'recent.deleteConfirm': '删除这条记录?这会删除本局正文与状态,不会删除好友或其他记录。此操作不可撤销。', + 'recent.status.active': '进行中', 'recent.status.in_progress': '生成中', 'recent.status.read_only': '只读', 'recent.status.ended': '已结束', 'recent.status.abandoned': '已放弃(未完成)', 'recent.status.paused': '已暂停', + 'recent.story': '故事', 'recent.game': '桌游', 'recent.rematch': '再开局', + 'chat.placeholder': '和 {name} 说点什么…(Enter 发送,Shift+Enter 换行)', 'chat.label': '给 {name} 的消息', + 'chat.asterisk': '用 * 包裹动作(选中文字后点击)', 'chat.hints': '回答提示', 'chat.hintsTitle': '回答提示 · 模拟候选', 'chat.hintsNote': '只填入输入框,发送后才提交。', + 'chat.keepDraft': '保留草稿', 'chat.useHint': '使用提示', 'chat.draftExists': '输入框里已有草稿,要用提示替换吗?', + 'chat.sending': '正在发送…', 'chat.generating': '{name} 正在回应…', 'chat.awaiting': '等待服务端确认…', 'chat.cancelling': '正在停止…', + 'chat.receipt': '你的消息', 'chat.receiptPending': '已发送,等待确认', 'chat.provisional': '回应中(尚未提交)', + 'chat.notice.commit_won': '这条回复在停止前已经完成,结果已保留(没有被停止)。', 'chat.notice.cancelled': '已停止。半段回复没有进入记录,原输入已放回输入框。', + 'chat.notice.retryable': '这次生成中断了。原输入已保留,可以明确“重试”同一条(不会重复发送)。', 'chat.notice.failed': '没能完成这次回复。草稿已保留。', + 'chat.notice.stale': '会话在别处有了新内容。已读取最新版本,你的草稿保留在输入框,没有自动重发。', 'chat.notice.epoch_changed': '这位好友的状态在别处发生了变化(例如重置)。旧会话已变为只读,请重新打开。', + 'chat.notice.unknown': '尚未确认这条消息是否被接收。请先“查询原请求”,不要重新发送。', 'chat.notice.read_only': '这段会话现在只读,不能继续发送。', + 'chat.notice.auth': '登录已过期。草稿已保留,重新登录后可以继续。', 'chat.notice.forbidden': '没有权限执行这项操作。', 'chat.notice.lookup_missing': '服务端没有收到这条消息。可以用同一请求重新发送(不会产生两条)。', + 'chat.notice.not_retryable': '这条已无法重试,已读取最新状态。', 'chat.notice.network': '连接中断。你的输入已保留。', + 'chat.lookup': '查询原请求', 'chat.resend': '用同一请求重新发送', 'chat.retry': '重试同一条', 'chat.newMessages': '{n} 条新消息', 'chat.toBottom': '回到最新', + 'chat.replyMode': '本地模拟回复', 'chat.replyModeNote': '回复来自本地固定模板,不是 Luna 或其他模型。', 'chat.readOnly': '只读会话', 'chat.readOnlyNote': '这段会话属于旧代次或已移除的好友,只能回看。', + 'chat.tools': '工具', 'chat.playTogether': '一起玩', 'chat.profile': '资料', 'chat.rename': '重命名', 'chat.reset': '重置好友', 'chat.remove': '移除好友', 'chat.correct': '纠正事实', + 'chat.rollback': '回退到这里', 'chat.rollbackConfirm': '回退到这条消息之前?这条和之后的 {n} 轮会从有效记录中撤回(不可再读),这条消息的原文会放回输入框。', 'chat.rollbackDone': '已回退,原输入已放回输入框。', + 'chat.correctTitle': '纠正事实', 'chat.correctDesc': '更正会作为“玩家说明”记录,不会改写已经发生的事;要撤销已发生的行动请用“回退到这里”。', 'chat.correctLabel': '更正内容', + 'chat.renameTitle': '重命名好友', 'chat.resetTitle': '重置好友', 'chat.resetDesc': '重置会让 {name} 回到首次建立时的基线并开启新代次。旧聊天与相关旧故事变为只读(不会删除)。此操作不可撤销。', + 'chat.removeTitle': '移除好友', 'chat.removeDesc': '{name} 会离开好友列表。各故事局保留移除时的状态继续;旧私聊归档为只读。其他好友不受影响。', + 'chat.liveNote': '演示音轨', 'chat.voiceOff': '无语音服务:文字为准,嘴型只跟随明确标注的演示音轨。', 'chat.demoAudio': '播放演示音轨', 'chat.stopAudio': '停止演示音轨', + 'chat.ignoredFrames': '已拒绝 {n} 个旧 run 的晚到帧', 'chat.empty': '会话从 {name} 的开场白开始。', 'chat.history': '较早的会话', + 'chat.action': '动作', 'chat.narration': '旁白', 'chat.slashCorrect': '/纠错', + 'expr.neutral': '平静', 'expr.happy': '开心', 'expr.surprised': '惊讶', + 'live2d.unavailable': '动态形象不可用,显示同一角色的静态图', 'live2d.loading': '正在载入动态形象…', 'live2d.paused': '已暂停(不在视野内)', 'live2d.static': '静态形象', + 'live2d.zoom': '放大', 'live2d.unzoom': '退出放大', + 'story.start': '开始准备', 'story.continueExisting': '继续已有的一局', 'story.newRun': '另开一局', 'story.playerRole': '你的身份', 'story.roles': '公开人物', + 'story.prepare': '故事准备', 'story.cast': '原阵容与出演', 'story.invite': '邀请好友出演', 'story.uninvite': '恢复默认', 'story.locked': '作者锁定', + 'story.aiCount': 'AI 位:{n} / 10', 'story.startConfirm': '确认开局', 'story.noRecord': '确认前不会创建任何记录。', 'story.friendUsed': '这位好友已出演另一个角色', + 'story.adapt': '出演说明:好友保持自己的长期身份与说话方式;本局职业、能力和知情范围由角色位决定,不会写回好友本人。', + 'story.receipt': '你的行动', 'story.validating': '世界回应中(整轮检查通过后才显示正文)…', 'story.continueStory': '继续剧情', 'story.continueNote': '推进下一段,不替你说话或做决定', + 'story.input': '描述你的行动或说的话…', 'story.mode.text': '文字', 'story.mode.scene': '场景', 'story.next': '下一段', 'story.known': '已知状态', 'story.objectives': '目标', + 'story.clues': '已取得线索', 'story.time': '局内时间:{m} 分钟', 'story.present': '在场人物', 'story.hint': '行动提示', 'story.narrationMode': '本地模拟叙事(固定剧情片段)', + 'story.addFriend': '把 {name} 加为好友', 'story.addFriendNote': '加为好友的是“正在这一局里的 Ta 本人”,并继承 Ta 有权知道的经历。', + 'story.anotherFriend': '另建一位独立好友', 'story.readOnly': '这一局现在只读(相关好友已重置)。', 'story.openMode': '主要情节已经展开,你可以继续自由行动。', + 'game.prepare': '游戏准备', 'game.profile': '规则', 'game.players': '人数', 'game.seats': '席位', 'game.seat': '席位 {n}', 'game.you': '你(席位 0)', + 'game.defaultLineup': '默认临时阵容', 'game.useFriend': '换成我的好友', 'game.start': '开始', 'game.starting': '正在开局…', 'game.noRecord': '点击“开始”前不会创建牌局。', + 'game.friendSeated': '这位好友已经入座', 'game.rules': '规则', 'game.pause': '暂停 AI', 'game.resume': '继续', 'game.paused': 'AI 行动已暂停,可以先聊聊。', + 'game.leftPaused': '离开期间后续行动已暂停。准备好后点“继续”。', 'game.yourTurn': '轮到你了', 'game.waiting': '等待 {name}…', 'game.aiThinking': '{name} 正在行动…', + 'game.abandon': '放弃本局', 'game.abandonConfirm': '放弃后本局变为只读并标为“未完成”,不会编造胜负。确定吗?', 'game.leave': '离开牌桌', 'game.leaveNote': '离开不会放弃;回来可以继续同一局。', + 'game.sidebar': '人物与桌聊', 'game.immersive': '沉浸', 'game.exitImmersive': '退出沉浸', 'game.fullscreen': '全屏', 'game.exitFullscreen': '退出全屏', 'game.fullscreenDenied': '浏览器拒绝了全屏,已保留当前布局。', + 'game.tableChat': '桌聊(同桌公开)', 'game.tableChatNote': '聊天不会出牌、不会改比分。', 'game.say': '对同桌说点什么…', 'game.replyFailed': '这次没能生成台词;牌局已提交的内容不受影响。', + 'game.result': '本局结果', 'game.winner': '胜者', 'game.score': '得分', 'game.rematch': '原桌再来一局', 'game.toChat': '去私聊', 'game.backSource': '回到来源', + 'game.ended': '已结束 · 结果已锁定', 'game.abandoned': '已放弃 · 未完成(只读)', 'game.befriend': '加为好友', 'game.befriendNote': '加为好友的是这张桌上的 Ta 本人,不增座、不重开。', + 'game.ruleSimulated': '规则模拟陪玩', 'game.ruleSimulatedNote': '同桌按固定规则策略行动,只使用自己可见的信息;聊天不会改变它的选择(JEV 未接通)。', + 'game.log': '公开记录', 'game.revision': '版本 r{n}', 'game.lookup': '正在核对开局请求…', 'game.selectHint': '选择后可以修改或取消,确认才提交。', + 'game.v1': '旧版 v1 存档(保持原规则)', + 'create.title': '创作', 'create.subtitle': '先选故事或角色,再命名和编辑。', 'create.newStory': '新建故事', 'create.newCharacter': '新建角色', 'create.empty': '你还没有作品。', + 'create.chooseType': '要创作什么?', 'create.typeFixed': '类型创建后固定。', 'create.nameLabel': '作品名字', 'create.create': '创建', 'create.story': '故事', 'create.character': '角色', + 'create.saved': '已保存 v{n}', 'create.unsaved': '有未保存的修改', 'create.saving': '保存中…', 'create.saveFailed': '保存失败,草稿仍在。', 'create.draftSaved': '草稿已同步', 'create.draftSaving': '草稿同步中…', + 'create.save': '保存版本', 'create.share': '分享', 'create.shareOn': '已分享 v{n}', 'create.shareOff': '未分享(默认)', 'create.shareNote': '分享需要已保存且检查通过的版本;分享不包含试演、私聊或私人记忆。', + 'create.shareEnable': '分享 v{n}', 'create.shareDisable': '关闭分享', 'create.shareLink': '本地链接', 'create.assist': 'AI 辅助改稿(本地模板)', 'create.assistNote': '本地规则模板,不调用模型;修改可撤销。', + 'create.assist.fill': '补全空白字段', 'create.assist.shorter': '缩短简介', 'create.assist.add_role': '加一个角色位', 'create.assist.warmer': '表达更亲近', 'create.undo': '撤销上次修改', + 'create.preview': '试演', 'create.previewNote': '试演隔离:不写入正式好友、私聊或记忆。', 'create.previewStart': '开始试演', 'create.previewRestart': '重新试演', + 'create.previewStale': '草稿已更新。当前试演仍使用 r{n} 的旧快照;重新试演才会使用新版本。', 'create.previewBlocked': '还有必填项未完成,暂不能试演或分享。', + 'create.addFriend': '用 v{n} 加为好友', 'create.fields': '编辑', 'create.expand': '放大试演', 'create.collapse': '返回编辑', + 'create.f.name': '名字', 'create.f.tagline': '一句话身份', 'create.f.intro': '公开介绍', 'create.f.persona': '人物设定', 'create.f.habit': '表达习惯', 'create.f.background': '背景', + 'create.f.opening': '开场', 'create.f.voice': '预设声音', 'create.f.portrait': '肖像', 'create.f.portraitNote': '自创角色暂用中性占位;不能借用官方角色的脸。', + 'create.f.cover': '封面', 'create.coverN': '封面 {n}', 'create.coverNote': '只用于你的作品卡片和编辑页。', + 'create.f.title': '作品名', 'create.f.genre': '题材', 'create.f.contentNote': '内容提示', 'create.f.setting': '世界设定', 'create.f.playerEntry': '玩家入场', 'create.f.narrative': '叙事偏好', + 'create.f.mode': '阶段模式', 'create.f.roles': '角色位(最多 10 个 AI)', 'create.f.roleTitle': '角色位名称', 'create.f.roleDesc': '公开说明', 'create.f.roleCharacter': '默认人物', + 'create.f.replaceable': '允许好友出演', 'create.f.locked': '锁定此位', 'create.f.worldbook': '世界书 / 知情规则', 'create.f.wbTitle': '条目', 'create.f.wbContent': '内容', 'create.f.wbKnow': '谁知道', + 'create.f.items': '属性 / 物品', 'create.f.stages': '阶段', 'create.addRole': '添加角色位', 'create.addEntry': '添加条目', 'create.remove': '移除', + 'create.mode.open': '开放式', 'create.mode.ending': '有结局', 'create.narr.balanced': '均衡', 'create.narr.dialogue': '多对白', 'create.narr.atmosphere': '重氛围', + 'create.err.required': '必填', 'create.err.too_long': '太长', 'create.err.too_many': '超过上限', 'create.err.invalid': '无效', + 'create.guestSave': '保存私有作品需要登录;草稿先保留在本机。', 'create.versions': '已保存版本', 'create.loginToSave': '登录后保存', + 'shell.skip': '跳到主要内容', 'shell.lang': '中', 'shell.taskExit': '退出任务页', + 'story.runMeta': '{turns} 段 · 更新于 {time}', 'story.readOnlyShort': '只读', 'story.friendCanPlay': '可由好友出演', + 'story.publicNote': '这里只展示公开介绍;任何人的私人经历都不会出现在作品页。', 'story.playedBy': '由 {name} 扮演', 'story.lockedNote': '作者锁定:保持原人物', + 'story.noFriends': '还没有好友可以邀请;也可以先用原阵容开始。', 'story.startUnknown': '网络中断,结果未知。再次确认会用同一请求,不会重复开局。', 'story.startRetry': '用同一请求重试', + 'story.youAre': '你:{role}', 'story.view': '显示方式', 'story.unknownResult': '网络中断,这一步的结果未知。', 'story.retrySame': '用同一请求重试', + 'story.failed': '这一轮没有完成(可重试,未写入任何内容)。', 'story.giveUp': '放弃并取回输入', 'story.conflict': '故事已在别处更新,已载入最新内容。', 'story.noClues': '还没有线索。', + 'story.stateNote': '状态与已提交正文同版本(r{rev})。', 'story.rollback': '回退到这里', 'story.rollbackConfirm': '回退会撤回这条行动及之后的全部正文与状态变化,并把这条行动放回输入框。确定吗?', + 'story.withdrawn': '(这条行动已撤回)', 'story.newClue': '新线索:{clue}', + 'recent.emptyArchived': '没有已归档的记录。', 'recent.goDiscover': '去发现', 'recent.gameMeta': '版本 r{rev} · 更新于 {time}', 'create.new': '新建作品', + 'create.emptyNote': '先选择故事或角色,再给它起个名字。', 'create.neverSaved': '尚未保存版本', 'create.storyNote': '设定、角色位、开场与叙事偏好', 'create.characterNote': '人物设定、表达习惯、背景与开场', + 'create.unchangedSave': '没有新修改,仍是 v{n}', 'create.unsavedDraft': '有未同步的修改', 'create.conflict': '草稿冲突', 'create.syncError': '草稿同步失败', + 'create.guestNote': '游客也可以编辑草稿;保存版本与分享需要登录。', 'create.conflictNote': '这份草稿在别处被修改了。你的修改还在本页,没有被覆盖。', 'create.loadLatest': '载入最新(放弃本页修改)', 'create.keepMine': '用本页内容继续', + 'create.assist.addRole': '增加角色位', 'create.voice.preset-neutral': '中性(预设)', 'create.voice.preset-warm': '温和(预设)', 'create.voice.preset-bright': '明亮(预设)', + 'create.voice.preset-low': '低沉(预设)', 'create.voice.preset-calm': '平静(预设)', 'create.roleN': '角色位 {n}', + 'create.worldbookGap': '世界书、属性/物品规则需要独立的保存服务:未接通,本版不提供入口。', 'create.previewSnapshot': '快照 r{n}', 'create.previewIdle': '开始试演后可以直接对话;试演只使用当时的草稿快照。', + 'create.previewSay': '在试演里说点什么…', 'create.previewClose': '结束试演', +} +export type I18nKey = keyof typeof zh +const en: Record = { + 'brand': 'Everwith', + 'mock.badge': 'Local demo', 'mock.badge.long': 'Local demo · simulated account & replies · no live services', 'mock.panel': 'Demo controls', 'mock.panel.title': 'Local demo · mock scenarios', + 'mock.panel.desc': 'These switches only affect the local mock service in this browser, to check failure, race and fallback states.', + 'mock.panel.reset': 'Erase local demo data', 'mock.panel.resetConfirm': 'This deletes the simulated account, friends, chats, games and works in this browser. Continue?', + 'mock.panel.legacy': 'Import a Splendor v1 legacy save sample', 'mock.panel.once': 'once', 'mock.panel.toggle': 'on', + 'nav.friends': 'Friends', 'nav.discover': 'Discover', 'nav.recent': 'Recent', 'nav.create': 'Create', 'nav.account': 'Account', 'nav.collapse': 'Collapse navigation', 'nav.expand': 'Expand navigation', 'nav.main': 'Main navigation', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.close': 'Close', 'common.retry': 'Retry', 'common.loading': 'Loading…', + 'common.save': 'Save', 'common.continue': 'Continue', 'common.more': 'More', 'common.done': 'Done', 'common.edit': 'Edit', 'common.delete': 'Delete', + 'common.archive': 'Archive', 'common.restore': 'Restore', 'common.unavailable': 'Unavailable', 'common.signin': 'Sign in', 'common.send': 'Send', 'common.stop': 'Stop', + 'common.optional': 'optional', 'common.required': 'required', 'common.search': 'Search', 'common.clear': 'Clear', 'common.players': 'players', + 'common.you': 'You', 'common.friend': 'Friend', 'common.temporary': 'Table guest', 'common.npc': 'Default character', 'common.simulated': 'simulated', + 'error.network': 'Connection lost. Your input is kept; you can retry.', 'error.generic': 'This could not be completed. Nothing you kept is lost; try again later.', + 'error.auth': 'Please sign in again. Your draft and position are kept.', 'error.forbidden': 'You do not have access to this.', 'error.notFound': 'Not found. It may have been deleted or you lack access.', + 'error.conflict': 'This was updated elsewhere; the latest version was loaded.', + 'discover.title': 'Discover', 'discover.subtitle': 'Find someone, or something to do together.', + 'discover.tab.all': 'All', 'discover.tab.characters': 'Characters', 'discover.tab.stories': 'Worlds (stories)', 'discover.tab.games': 'Games', + 'discover.search': 'Search characters, stories or games', 'discover.empty.search': 'Nothing matches “{q}”.', 'discover.empty.clear': 'Clear search', + 'discover.empty.catalog': 'Nothing here yet.', 'discover.error': 'Loading failed (this is not “no content”).', 'discover.appendError': 'Loading more failed; what is loaded stays.', + 'discover.loadMore': 'Load more', 'discover.kind.character': 'Character', 'discover.kind.story': 'World · story', 'discover.kind.game': 'Game', + 'discover.players': '{n} players', 'discover.companionGame': 'Friends can join', 'discover.continue': 'Continue', + 'friends.title': 'Friends', 'friends.subtitle': 'Long-term friends remember what you went through together.', 'friends.empty': 'No friends yet.', 'friends.emptyHint': 'Meet a character in Discover, or add a companion from a story or a game table.', + 'friends.guest': 'Sign in to make and keep friends.', 'friends.chat': 'Chat', 'friends.play': 'Play together', 'friends.source': 'Source: {name}', 'friends.since': 'Since {date}', + 'friends.number': '#{n}', 'friends.search': 'Find by name or source', + 'character.add': 'Add as friend', 'character.existing': 'Your friends from this source', 'character.existingHint': 'One source can have several independent friends; each remembers their own experiences.', + 'character.addAnother': 'Add another friend', 'character.chatWith': 'Chat with {name}', 'character.public': 'Public introduction', 'character.traits': 'Speaking habits', + 'character.noPrivate': 'Only the author’s public introduction is shown here — never anyone’s private chats or experiences.', 'character.voice': 'Preset voice (no voice service connected)', + 'name.title': 'Name your friend', 'name.desc': 'The name is your private way to call them; the source name “{source}” only tells them apart. Renaming never changes personality.', + 'name.label': 'Friend name', 'name.confirm': 'Add friend', 'name.required': 'Please enter a name.', 'name.creating': 'Creating friend…', + 'auth.title.login': 'Sign in', 'auth.title.register': 'Create account', 'auth.email': 'Email', 'auth.password': 'Password', 'auth.passwordHint': 'At least 8 characters. The local demo has no password recovery.', + 'auth.switchRegister': 'No account? Create one', 'auth.switchLogin': 'Have an account? Sign in', 'auth.submit.login': 'Sign in', 'auth.submit.register': 'Create & sign in', + 'auth.why': 'Sign in to {action}. Cancel returns you to where you were; your play and input are kept.', 'auth.mockNote': 'This is a local simulated account stored only in this browser — not a real authentication service.', + 'auth.error.invalid_credentials': 'Wrong email or password.', 'auth.error.email_taken': 'That email is registered; sign in instead.', 'auth.error.weak_password': 'Use at least 8 characters.', 'auth.error.invalid_email': 'That email looks invalid.', + 'auth.action.friend': 'add a friend', 'auth.action.save': 'save your work', 'auth.action.share': 'share a work', 'auth.action.chat': 'chat privately', 'auth.action.generic': 'continue', + 'account.title': 'Account & settings', 'account.guest': 'Guest (local)', 'account.signedIn': 'Signed in: {email}', 'account.logout': 'Sign out', 'account.persona': 'My persona', + 'account.personaName': 'What friends call you', 'account.personaBio': 'Something friends may know (optional)', 'account.personaSave': 'Save persona', 'account.personaSaved': 'Saved', + 'account.assets': 'Asset sources', 'account.assets.package': 'Handoff package art (character art, Live2D, table art, preview clips): privately handed over with the user\'s permission; commercial / redistribution rights unverified.', + 'account.assets.generated': 'Generated art (story covers and scenes, character rooms, game covers, empty-state art, persona pictures, work covers, icons): generated with GPT and provided by the user; commercial / redistribution rights unverified. For this private demo only.', + 'account.personaAvatar': 'Picture', 'account.avatarNone': 'No picture (show initial)', 'account.avatarNote': 'Optional small object pictures — never a person, so they cannot be mistaken for a character. Saved with your persona.', + 'account.language': 'Interface language', 'account.languageNote': 'Applies immediately; existing messages are never re-translated, new sends use the new language.', 'account.noRecovery': 'No password recovery in this phase (not a missing button).', + 'account.motion': 'Reduce motion', 'account.motionNote': 'Follows your system setting; characters keep only essential expression and blinking.', 'account.guestNote': 'Guests can browse and play; adding friends and saving works need an account. Signing in safely merges this browser’s guest progress.', + 'recent.title': 'Recent', 'recent.subtitle': 'Find a specific story run or game.', 'recent.empty': 'No records yet.', 'recent.archived': 'Archived', 'recent.active': 'Active & finished', + 'recent.continue': 'Continue', 'recent.review': 'Review', 'recent.lineup': 'Lineup', 'recent.deleteConfirm': 'Delete this record? Its text and state are removed; friends and other records are not. This cannot be undone.', + 'recent.status.active': 'In progress', 'recent.status.in_progress': 'Generating', 'recent.status.read_only': 'Read-only', 'recent.status.ended': 'Finished', 'recent.status.abandoned': 'Abandoned (unfinished)', 'recent.status.paused': 'Paused', + 'recent.story': 'Story', 'recent.game': 'Game', 'recent.rematch': 'Rematch', + 'chat.placeholder': 'Say something to {name}… (Enter to send, Shift+Enter for a new line)', 'chat.label': 'Message to {name}', + 'chat.asterisk': 'Wrap an action in * (select text, then click)', 'chat.hints': 'Reply hints', 'chat.hintsTitle': 'Reply hints · simulated', 'chat.hintsNote': 'Fills the composer only; nothing is sent until you send.', + 'chat.keepDraft': 'Keep draft', 'chat.useHint': 'Use hint', 'chat.draftExists': 'You already have a draft. Replace it with the hint?', + 'chat.sending': 'Sending…', 'chat.generating': '{name} is replying…', 'chat.awaiting': 'Waiting for the server to confirm…', 'chat.cancelling': 'Stopping…', + 'chat.receipt': 'Your message', 'chat.receiptPending': 'Sent, awaiting confirmation', 'chat.provisional': 'Replying (not committed yet)', + 'chat.notice.commit_won': 'This reply finished before Stop arrived; the result is kept (it was not stopped).', 'chat.notice.cancelled': 'Stopped. The partial reply was not recorded; your input is back in the composer.', + 'chat.notice.retryable': 'Generation was interrupted. Your input is kept; you can explicitly Retry the same message (no duplicate).', 'chat.notice.failed': 'The reply could not be completed. Your draft is kept.', + 'chat.notice.stale': 'The conversation changed elsewhere. The latest version is loaded; your draft stays in the composer and nothing was resent.', 'chat.notice.epoch_changed': 'This friend changed elsewhere (e.g. reset). The old conversation is now read-only; please reopen.', + 'chat.notice.unknown': 'Not confirmed whether the message arrived. Look up the original request first — do not resend.', 'chat.notice.read_only': 'This conversation is read-only now.', + 'chat.notice.auth': 'Your sign-in expired. The draft is kept; sign in again to continue.', 'chat.notice.forbidden': 'You do not have permission to do that.', 'chat.notice.lookup_missing': 'The server never received it. You can resend the same request (no duplicate will be created).', + 'chat.notice.not_retryable': 'It can no longer be retried; the latest state was loaded.', 'chat.notice.network': 'Connection lost. Your input is kept.', + 'chat.lookup': 'Look up original request', 'chat.resend': 'Resend same request', 'chat.retry': 'Retry the same message', 'chat.newMessages': '{n} new', 'chat.toBottom': 'Jump to latest', + 'chat.replyMode': 'Local simulated reply', 'chat.replyModeNote': 'Replies come from fixed local templates, not Luna or any model.', 'chat.readOnly': 'Read-only conversation', 'chat.readOnlyNote': 'This conversation belongs to an older generation or a removed friend and can only be reviewed.', + 'chat.tools': 'Tools', 'chat.playTogether': 'Play together', 'chat.profile': 'Profile', 'chat.rename': 'Rename', 'chat.reset': 'Reset friend', 'chat.remove': 'Remove friend', 'chat.correct': 'Correct a fact', + 'chat.rollback': 'Roll back to here', 'chat.rollbackConfirm': 'Roll back to before this message? It and the {n} later turn(s) are withdrawn from the valid record (no longer readable); its text goes back into the composer.', 'chat.rollbackDone': 'Rolled back; your original input is in the composer.', + 'chat.correctTitle': 'Correct a fact', 'chat.correctDesc': 'A correction is recorded as your statement and never rewrites what happened; to undo an action use “Roll back to here”.', 'chat.correctLabel': 'Correction', + 'chat.renameTitle': 'Rename friend', 'chat.resetTitle': 'Reset friend', 'chat.resetDesc': 'Reset returns {name} to their first baseline and starts a new generation. Old chats and related stories become read-only (not deleted). This cannot be undone.', + 'chat.removeTitle': 'Remove friend', 'chat.removeDesc': '{name} leaves your friends. Stories keep their state at removal and continue; old chats are archived read-only. Other friends are unaffected.', + 'chat.liveNote': 'Demo track', 'chat.voiceOff': 'No voice service: text is authoritative; the mouth only follows the clearly labelled demo track.', 'chat.demoAudio': 'Play demo track', 'chat.stopAudio': 'Stop demo track', + 'chat.ignoredFrames': 'Rejected {n} late frame(s) from an old run', 'chat.empty': 'The conversation begins with {name}’s greeting.', 'chat.history': 'Earlier conversations', + 'chat.action': 'Action', 'chat.narration': 'Narration', 'chat.slashCorrect': '/correct', + 'expr.neutral': 'Calm', 'expr.happy': 'Happy', 'expr.surprised': 'Surprised', + 'live2d.unavailable': 'Animated portrait unavailable; showing the same character’s still image', 'live2d.loading': 'Loading animated portrait…', 'live2d.paused': 'Paused (off-screen)', 'live2d.static': 'Still portrait', + 'live2d.zoom': 'Enlarge', 'live2d.unzoom': 'Exit enlarge', + 'story.start': 'Prepare', 'story.continueExisting': 'Continue a saved run', 'story.newRun': 'Start a new run', 'story.playerRole': 'Your role', 'story.roles': 'Public characters', + 'story.prepare': 'Story setup', 'story.cast': 'Original cast & who plays whom', 'story.invite': 'Invite a friend', 'story.uninvite': 'Use default', 'story.locked': 'Locked by author', + 'story.aiCount': 'AI seats: {n} / 10', 'story.startConfirm': 'Start this run', 'story.noRecord': 'Nothing is created until you confirm.', 'story.friendUsed': 'This friend already plays another role', + 'story.adapt': 'Casting note: friends keep their long-term identity and voice; this run’s job, abilities and knowledge come from the role and never write back to the friend.', + 'story.receipt': 'Your action', 'story.validating': 'The world is responding (text appears only after the whole turn is checked)…', 'story.continueStory': 'Continue story', 'story.continueNote': 'Advances the next beat without speaking or deciding for you', + 'story.input': 'Describe what you do or say…', 'story.mode.text': 'Text', 'story.mode.scene': 'Scene', 'story.next': 'Next beat', 'story.known': 'Known state', 'story.objectives': 'Objectives', + 'story.clues': 'Clues found', 'story.time': 'In-story time: {m} min', 'story.present': 'Present', 'story.hint': 'Action hint', 'story.narrationMode': 'Local simulated narration (fixed beats)', + 'story.addFriend': 'Add {name} as a friend', 'story.addFriendNote': 'You befriend this very person in this run, with the experiences they are entitled to know.', + 'story.anotherFriend': 'Add a separate friend', 'story.readOnly': 'This run is read-only (a related friend was reset).', 'story.openMode': 'The main plot has unfolded; you can keep acting freely.', + 'game.prepare': 'Game setup', 'game.profile': 'Rules', 'game.players': 'Players', 'game.seats': 'Seats', 'game.seat': 'Seat {n}', 'game.you': 'You (seat 0)', + 'game.defaultLineup': 'Default table guests', 'game.useFriend': 'Use my friend', 'game.start': 'Start', 'game.starting': 'Starting…', 'game.noRecord': 'No game exists until you press Start.', + 'game.friendSeated': 'This friend is already seated', 'game.rules': 'Rules', 'game.pause': 'Pause AI', 'game.resume': 'Continue', 'game.paused': 'AI moves are paused; chat first if you like.', + 'game.leftPaused': 'Further moves paused while you were away. Press Continue when ready.', 'game.yourTurn': 'Your turn', 'game.waiting': 'Waiting for {name}…', 'game.aiThinking': '{name} is moving…', + 'game.abandon': 'Abandon game', 'game.abandonConfirm': 'Abandoning makes this game read-only and unfinished; no winner is invented. Continue?', 'game.leave': 'Leave table', 'game.leaveNote': 'Leaving does not abandon; come back to continue the same game.', + 'game.sidebar': 'Characters & table chat', 'game.immersive': 'Immersive', 'game.exitImmersive': 'Exit immersive', 'game.fullscreen': 'Fullscreen', 'game.exitFullscreen': 'Exit fullscreen', 'game.fullscreenDenied': 'The browser refused fullscreen; the current layout is kept.', + 'game.tableChat': 'Table chat (public to the table)', 'game.tableChatNote': 'Chat never plays a move or changes the score.', 'game.say': 'Say something to the table…', 'game.replyFailed': 'No line could be generated this time; the committed game is unaffected.', + 'game.result': 'Result', 'game.winner': 'Winner', 'game.score': 'Score', 'game.rematch': 'Rematch at this table', 'game.toChat': 'Private chat', 'game.backSource': 'Back to source', + 'game.ended': 'Finished · result locked', 'game.abandoned': 'Abandoned · unfinished (read-only)', 'game.befriend': 'Add as friend', 'game.befriendNote': 'You befriend this very table-mate — no extra seat, no restart.', + 'game.ruleSimulated': 'Rule-simulated companions', 'game.ruleSimulatedNote': 'Table-mates follow a fixed rule strategy using only what their seat can see; chat does not change their choices (JEV not connected).', + 'game.log': 'Public log', 'game.revision': 'Revision r{n}', 'game.lookup': 'Checking the start request…', 'game.selectHint': 'You can change or cancel a selection; only Confirm submits.', + 'game.v1': 'Legacy v1 save (original rules kept)', + 'create.title': 'Create', 'create.subtitle': 'Pick a story or a character first, then name and edit it.', 'create.newStory': 'New story', 'create.newCharacter': 'New character', 'create.empty': 'No works yet.', + 'create.chooseType': 'What do you want to create?', 'create.typeFixed': 'The type is fixed after creation.', 'create.nameLabel': 'Work name', 'create.create': 'Create', 'create.story': 'Story', 'create.character': 'Character', + 'create.saved': 'Saved v{n}', 'create.unsaved': 'Unsaved changes', 'create.saving': 'Saving…', 'create.saveFailed': 'Save failed; your draft is still here.', 'create.draftSaved': 'Draft synced', 'create.draftSaving': 'Syncing draft…', + 'create.save': 'Save version', 'create.share': 'Share', 'create.shareOn': 'Shared v{n}', 'create.shareOff': 'Not shared (default)', 'create.shareNote': 'Sharing needs a saved version that passes checks; shares never include previews, private chats or memories.', + 'create.shareEnable': 'Share v{n}', 'create.shareDisable': 'Stop sharing', 'create.shareLink': 'Local link', 'create.assist': 'AI-assisted edit (local templates)', 'create.assistNote': 'Local rule templates, no model is called; changes can be undone.', + 'create.assist.fill': 'Fill empty fields', 'create.assist.shorter': 'Shorten summary', 'create.assist.add_role': 'Add a role', 'create.assist.warmer': 'Warmer speaking style', 'create.undo': 'Undo last change', + 'create.preview': 'Preview', 'create.previewNote': 'Isolated preview: nothing is written to friends, chats or memory.', 'create.previewStart': 'Start preview', 'create.previewRestart': 'Restart preview', + 'create.previewStale': 'The draft changed. This preview still uses the r{n} snapshot; restart to use the new version.', 'create.previewBlocked': 'Required fields are missing; preview and sharing are unavailable.', + 'create.addFriend': 'Add as friend from v{n}', 'create.fields': 'Edit', 'create.expand': 'Enlarge preview', 'create.collapse': 'Back to editing', + 'create.f.name': 'Name', 'create.f.tagline': 'One-line identity', 'create.f.intro': 'Public introduction', 'create.f.persona': 'Personality', 'create.f.habit': 'Speaking habits', 'create.f.background': 'Background', + 'create.f.opening': 'Opening', 'create.f.voice': 'Preset voice', 'create.f.portrait': 'Portrait', 'create.f.portraitNote': 'Your characters use a neutral placeholder for now; official faces cannot be borrowed.', + 'create.f.cover': 'Cover', 'create.coverN': 'Cover {n}', 'create.coverNote': 'Only used on your work cards and in the editor.', + 'create.f.title': 'Title', 'create.f.genre': 'Genre', 'create.f.contentNote': 'Content note', 'create.f.setting': 'World setting', 'create.f.playerEntry': 'Player entry', 'create.f.narrative': 'Narrative preference', + 'create.f.mode': 'Stage mode', 'create.f.roles': 'Roles (up to 10 AI)', 'create.f.roleTitle': 'Role name', 'create.f.roleDesc': 'Public description', 'create.f.roleCharacter': 'Default character', + 'create.f.replaceable': 'Friends may play it', 'create.f.locked': 'Lock this role', 'create.f.worldbook': 'Worldbook / who knows what', 'create.f.wbTitle': 'Entry', 'create.f.wbContent': 'Content', 'create.f.wbKnow': 'Known by', + 'create.f.items': 'Attributes / items', 'create.f.stages': 'Stages', 'create.addRole': 'Add role', 'create.addEntry': 'Add entry', 'create.remove': 'Remove', + 'create.mode.open': 'Open-ended', 'create.mode.ending': 'Has an ending', 'create.narr.balanced': 'Balanced', 'create.narr.dialogue': 'Dialogue-heavy', 'create.narr.atmosphere': 'Atmospheric', + 'create.err.required': 'Required', 'create.err.too_long': 'Too long', 'create.err.too_many': 'Over the limit', 'create.err.invalid': 'Invalid', + 'create.guestSave': 'Saving private works requires an account; your draft stays on this device meanwhile.', 'create.versions': 'Saved versions', 'create.loginToSave': 'Sign in to save', + 'shell.skip': 'Skip to main content', 'shell.lang': 'EN', 'shell.taskExit': 'Exit task page', + 'story.runMeta': '{turns} turns · updated {time}', 'story.readOnlyShort': 'read-only', 'story.friendCanPlay': 'A friend can play this', + 'story.publicNote': 'Only the public introduction is shown; nobody\'s private history appears on a work page.', 'story.playedBy': 'played by {name}', 'story.lockedNote': 'Locked by the author: keeps the original character', + 'story.noFriends': 'No friends to invite yet; you can start with the original cast.', 'story.startUnknown': 'Connection lost; the result is unknown. Confirming again reuses the same request and cannot create a second run.', 'story.startRetry': 'Retry the same request', + 'story.youAre': 'You: {role}', 'story.view': 'View', 'story.unknownResult': 'Connection lost; the result of this step is unknown.', 'story.retrySame': 'Retry the same request', + 'story.failed': 'This turn did not complete (retryable; nothing was written).', 'story.giveUp': 'Give up and get my input back', 'story.conflict': 'The story changed elsewhere; the latest version is loaded.', 'story.noClues': 'No clues yet.', + 'story.stateNote': 'State matches the committed text (r{rev}).', 'story.rollback': 'Roll back to here', 'story.rollbackConfirm': 'Rolling back withdraws this action and everything after it (text and state) and puts the action back in the input box. Continue?', + 'story.withdrawn': '(this action was withdrawn)', 'story.newClue': 'New clue: {clue}', + 'recent.emptyArchived': 'No archived records.', 'recent.goDiscover': 'Go to Discover', 'recent.gameMeta': 'Revision r{rev} · updated {time}', 'create.new': 'New work', + 'create.emptyNote': 'Pick a story or a character first, then name it.', 'create.neverSaved': 'No saved version yet', 'create.storyNote': 'Setting, roles, opening and narrative style', 'create.characterNote': 'Persona, speaking habits, background and opening', + 'create.unchangedSave': 'No changes; still v{n}', 'create.unsavedDraft': 'Unsynced changes', 'create.conflict': 'Draft conflict', 'create.syncError': 'Draft sync failed', + 'create.guestNote': 'Guests can edit drafts; saving versions and sharing need sign-in.', 'create.conflictNote': 'This draft changed elsewhere. Your edits are still on this page; nothing was overwritten.', 'create.loadLatest': 'Load latest (discard edits here)', 'create.keepMine': 'Keep my edits', + 'create.assist.addRole': 'Add a role', 'create.voice.preset-neutral': 'Neutral (preset)', 'create.voice.preset-warm': 'Warm (preset)', 'create.voice.preset-bright': 'Bright (preset)', + 'create.voice.preset-low': 'Low (preset)', 'create.voice.preset-calm': 'Calm (preset)', 'create.roleN': 'Role {n}', + 'create.worldbookGap': 'World book and item/attribute rules need a separate save service: not connected, so no entry is offered here.', 'create.previewSnapshot': 'Snapshot r{n}', 'create.previewIdle': 'Start a preview to talk; it uses the draft snapshot taken at that moment.', + 'create.previewSay': 'Say something in the preview…', 'create.previewClose': 'End preview', +} +export const DICTS: Record> = {zh, en} +export type T = (key: I18nKey, vars?: Record) => string +export function makeT(lang: Lang): T { + const d = DICTS[lang] + return (key, vars) => { + let s = d[key] ?? key + if (vars) for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v)) + return s + } +} +export const LangContext = createContext<{lang: Lang; t: T}>({lang: 'zh', t: makeT('zh')}) +export const useI18n = () => useContext(LangContext) diff --git a/everwith-claude-frontend/src/app/router.ts b/everwith-claude-frontend/src/app/router.ts new file mode 100644 index 00000000..56bc2c83 --- /dev/null +++ b/everwith-claude-frontend/src/app/router.ts @@ -0,0 +1,104 @@ +import {useSyncExternalStore} from 'react' + +export type DiscoverTab = 'all' | 'characters' | 'stories' | 'games' +export type Route = + | {name: 'root'} + | {name: 'discover'; tab: DiscoverTab; q: string} + | {name: 'friends'} + | {name: 'chat'; friendId: string} + | {name: 'character'; id: string; version: string} + | {name: 'story'; id: string; version: string} + | {name: 'storyPrepare'; id: string; version: string; withFriend: string | null; from: string | null} + | {name: 'storyPlay'; sessionId: string} + | {name: 'gamePrepare'; gameId: string; withFriend: string | null; from: string | null} + | {name: 'gamePlay'; sessionId: string} + | {name: 'recent'; archived: boolean} + | {name: 'create'} + | {name: 'work'; workId: string} + | {name: 'account'} + | {name: 'notFound'; hash: string} + +const TABS: DiscoverTab[] = ['all', 'characters', 'stories', 'games'] + +/** + * Old deep links (`#/companions?view=…&friend=…&storySession=…&gameSession=…`) map to the + * object they named, never to the default home (01_BUSINESS_AND_PAGE_MAP.md). + */ +export function mapLegacy(hash: string): string | null { + if (!hash.startsWith('#/companions')) return null + const params = new URLSearchParams(hash.split('?')[1] ?? '') + const game = params.get('gameSession'), story = params.get('storySession'), friend = params.get('friend') + if (game) return `#/play/game/${encodeURIComponent(game)}` + if (story) return `#/play/story/${encodeURIComponent(story)}` + if (friend) return `#/friends/${encodeURIComponent(friend)}` + const storyId = params.get('story') + if (storyId) return `#/stories/${encodeURIComponent(storyId)}?v=${encodeURIComponent(params.get('version') ?? '1')}` + const view = params.get('view') + if (view === 'friends') return '#/friends' + if (view === 'recent') return '#/recent' + if (view === 'create') return '#/create' + return '#/discover' +} + +export function parseHash(hash: string): Route { + if (!hash || hash === '#' || hash === '#/') return {name: 'root'} + const [rawPath, rawQuery = ''] = hash.replace(/^#/, '').split('?') + const parts = rawPath.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p) } catch { return p } }) + const q = new URLSearchParams(rawQuery) + const [a, b, c] = parts + if (a === 'discover' && !b) { const tab = q.get('tab') as DiscoverTab; return {name: 'discover', tab: TABS.includes(tab) ? tab : 'all', q: q.get('q') ?? ''} } + if (a === 'friends' && !b) return {name: 'friends'} + if (a === 'friends' && b && !c) return {name: 'chat', friendId: b} + if (a === 'characters' && b && !c) return {name: 'character', id: b, version: q.get('v') ?? '1'} + if (a === 'stories' && b && !c) return {name: 'story', id: b, version: q.get('v') ?? '1'} + if (a === 'stories' && b && c === 'prepare') return {name: 'storyPrepare', id: b, version: q.get('v') ?? '1', withFriend: q.get('with'), from: q.get('from')} + if (a === 'play' && b === 'story' && c) return {name: 'storyPlay', sessionId: c} + if (a === 'games' && b && c === 'prepare') return {name: 'gamePrepare', gameId: b, withFriend: q.get('with'), from: q.get('from')} + if (a === 'play' && b === 'game' && c) return {name: 'gamePlay', sessionId: c} + if (a === 'recent' && !b) return {name: 'recent', archived: q.get('archived') === '1'} + if (a === 'create' && !b) return {name: 'create'} + if (a === 'create' && b && !c) return {name: 'work', workId: b} + if (a === 'account' && !b) return {name: 'account'} + return {name: 'notFound', hash} +} + +export const href = { + discover: (tab: DiscoverTab = 'all', q = '') => `#/discover${tab !== 'all' || q ? `?${new URLSearchParams({...(tab !== 'all' ? {tab} : {}), ...(q ? {q} : {})})}` : ''}`, + friends: () => '#/friends', + chat: (friendId: string) => `#/friends/${encodeURIComponent(friendId)}`, + character: (id: string, version = '1') => `#/characters/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + story: (id: string, version = '1') => `#/stories/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + storyPrepare: (id: string, version = '1', withFriend?: string | null, from?: string | null) => `#/stories/${encodeURIComponent(id)}/prepare?${new URLSearchParams({v: version, ...(withFriend ? {with: withFriend} : {}), ...(from ? {from} : {})})}`, + storyPlay: (id: string) => `#/play/story/${encodeURIComponent(id)}`, + gamePrepare: (gameId: string, withFriend?: string | null, from?: string | null) => `#/games/${encodeURIComponent(gameId)}/prepare${withFriend || from ? `?${new URLSearchParams({...(withFriend ? {with: withFriend} : {}), ...(from ? {from} : {})})}` : ''}`, + gamePlay: (id: string) => `#/play/game/${encodeURIComponent(id)}`, + recent: (archived = false) => `#/recent${archived ? '?archived=1' : ''}`, + create: () => '#/create', + work: (id: string) => `#/create/${encodeURIComponent(id)}`, + account: () => '#/account', +} + +const listeners = new Set<() => void>() +let currentHash = typeof window !== 'undefined' ? window.location.hash : '' +function sync() { + const legacy = mapLegacy(window.location.hash) + if (legacy) { history.replaceState(history.state, '', legacy); } + if (window.location.hash !== currentHash) { currentHash = window.location.hash; listeners.forEach(fn => fn()) } +} +if (typeof window !== 'undefined') { + const legacy = mapLegacy(window.location.hash) + if (legacy) { history.replaceState(history.state, '', legacy); currentHash = legacy } + window.addEventListener('hashchange', sync) + window.addEventListener('popstate', sync) +} +export function navigate(hash: string, options: {replace?: boolean} = {}) { + if (hash === window.location.hash) return + if (options.replace) history.replaceState(history.state, '', hash) + else history.pushState(null, '', hash) + sync() +} +export function useRoute(): Route { + const hash = useSyncExternalStore(fn => { listeners.add(fn); return () => { listeners.delete(fn) } }, () => currentHash, () => '') + return parseHash(hash) +} +export const currentRouteHash = () => currentHash diff --git a/everwith-claude-frontend/src/app/store.ts b/everwith-claude-frontend/src/app/store.ts new file mode 100644 index 00000000..575ec206 --- /dev/null +++ b/everwith-claude-frontend/src/app/store.ts @@ -0,0 +1,82 @@ +/** App-level session state: account, language, friends cache, sign-in gate, toasts. */ +import {useSyncExternalStore} from 'react' +import {api} from '../api/adapter' +import {CompanionRequestError} from '../contracts/companion' +import type {AccountSession, CompanionFriend} from '../contracts/companion' +import type {I18nKey} from './i18n' +import {readJSON, writeJSON} from '../lib/util' +import type {Lang} from '../lib/util' + +export type Toast = {id: number; text: string; kind: 'info' | 'error' | 'success'} +export type AppState = { + lang: Lang + account: AccountSession | null + accountError: string | null + friends: CompanionFriend[] | null + friendsError: string | null + authPrompt: {action: I18nKey; id: number} | null + toasts: Toast[] + navCollapsed: boolean + reducedMotion: boolean +} +const LANG_KEY = 'everwith.ui.lang', NAV_KEY = 'everwith.ui.navCollapsed', MOTION_KEY = 'everwith.ui.reducedMotion' +const systemMotion = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches +let state: AppState = { + lang: readJSON(LANG_KEY, (typeof navigator !== 'undefined' && navigator.language.startsWith('en') ? 'en' : 'zh')), + account: null, accountError: null, friends: null, friendsError: null, authPrompt: null, toasts: [], + navCollapsed: readJSON(NAV_KEY, false), reducedMotion: readJSON(MOTION_KEY, null) ?? systemMotion, +} +const listeners = new Set<() => void>() +const set = (patch: Partial) => { state = {...state, ...patch}; listeners.forEach(fn => fn()) } +export const appStore = { + subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }, + get: () => state, +} +export function useApp(select: (s: AppState) => T): T { + return useSyncExternalStore(appStore.subscribe, () => select(state), () => select(state)) +} + +let toastSeq = 0 +export function toast(text: string, kind: Toast['kind'] = 'info') { + const id = ++toastSeq + set({toasts: [...state.toasts, {id, text, kind}].slice(-4)}) + setTimeout(() => set({toasts: state.toasts.filter(t => t.id !== id)}), 5200) +} +export function setLang(lang: Lang) { writeJSON(LANG_KEY, lang); document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; set({lang}) } +export function setNavCollapsed(v: boolean) { writeJSON(NAV_KEY, v); set({navCollapsed: v}) } +export function setReducedMotion(v: boolean) { writeJSON(MOTION_KEY, v); set({reducedMotion: v}) } + +export async function loadAccount() { + try { const account = await api.account.get(); set({account, accountError: null}); if (account.authenticated) void refreshFriends() ; else set({friends: []}) } + catch (e) { set({accountError: e instanceof CompanionRequestError ? e.code : 'network_error'}) } +} +export function setAccount(account: AccountSession) { + const changed = state.account?.account?.id !== account.account?.id + set({account, ...(changed ? {friends: null} : {})}) + if (account.authenticated) void refreshFriends(); else set({friends: []}) +} +let friendsFlight = 0 +export async function refreshFriends() { + const acc = state.account + if (!acc?.authenticated || !acc.account) { set({friends: []}); return } + const flight = ++friendsFlight + try { + const {items} = await api.friends.list(acc.account.id) + // A late answer for a previous account must never overwrite the current one. + if (flight === friendsFlight && state.account?.account?.id === acc.account.id) set({friends: items, friendsError: null}) + } catch (e) { + if (flight !== friendsFlight) return + if (e instanceof CompanionRequestError && e.code === 'auth_session_required') { void loadAccount(); return } + set({friendsError: e instanceof CompanionRequestError ? e.code : 'network_error'}) + } +} + +/** Sign-in gate for protected actions. Resolves true once signed in, false if cancelled. */ +let pendingAuth: ((ok: boolean) => void) | null = null, authSeq = 0 +export function requireSignIn(action: I18nKey): Promise { + if (state.account?.authenticated) return Promise.resolve(true) + pendingAuth?.(false) + return new Promise(resolve => { pendingAuth = resolve; set({authPrompt: {action, id: ++authSeq}}) }) +} +export function resolveAuth(ok: boolean) { const r = pendingAuth; pendingAuth = null; set({authPrompt: null}); r?.(ok) } +export function openSignIn(action: I18nKey = 'auth.action.generic') { void requireSignIn(action) } diff --git a/everwith-claude-frontend/src/art/generated.ts b/everwith-claude-frontend/src/art/generated.ts new file mode 100644 index 00000000..3b06882d --- /dev/null +++ b/everwith-claude-frontend/src/art/generated.ts @@ -0,0 +1,45 @@ +/** + * Images from the user-provided GPT batch v2-batch-01 — NOT part of the handoff package. + * Rights: 用户经 GPT 生成并提供;商用/再分发权未核实. Files are resized copies in + * public/generated/batch-01; originals, hashes, prompts and the chosen variants are in + * docs/GENERATED_ASSETS.json (`npm run assets:generated` re-checks them). + * Presentation only: a backdrop or cover never stands in for a character's face, and + * every image has a plain fallback when it is missing. + */ +import type {CharacterKey} from '../live2d/characters' + +export const GENERATED_RIGHTS = '用户经 GPT 生成并提供;商用/再分发权未核实' +export const GEN = '/generated/batch-01' + +/** Room behind a character's stage. Characters without their own (user-created works) get the generic room. */ +export const backdropFor = (key: CharacterKey | null | undefined): string => `${GEN}/backdrop-${key ?? 'generic'}.webp` + +export type SpotArtKind = 'friends' | 'recent' | 'create' | 'not-found' | 'load-error' | 'signin' +export const SPOT_ART: Record = { + friends: `${GEN}/empty-friends.webp`, recent: `${GEN}/empty-recent.webp`, create: `${GEN}/empty-create.webp`, + 'not-found': `${GEN}/state-not-found.webp`, 'load-error': `${GEN}/state-load-error.webp`, signin: `${GEN}/state-signin.webp`, +} +/** “Not found”, “sign in” and “failed to load” are different states with different pictures. */ +export const errorArt = (code: string | null | undefined): SpotArtKind => + code === 'not_found' ? 'not-found' : code === 'auth_session_required' ? 'signin' : 'load-error' + +export const CREATE_TYPE_ART = {story: `${GEN}/create-type-story.webp`, character: `${GEN}/create-type-character.webp`} as const + +/** Cover choices for the user's own works (stored in the draft as the id, never as a URL). */ +export const WORK_COVERS = ['work-cover-01', 'work-cover-02', 'work-cover-03', 'work-cover-04', 'work-cover-05', 'work-cover-06'] as const +export type WorkCoverId = (typeof WORK_COVERS)[number] +export const isWorkCover = (v: unknown): v is WorkCoverId => typeof v === 'string' && (WORK_COVERS as readonly string[]).includes(v) +export const workCoverUrl = (id: unknown): string | null => isWorkCover(id) ? `${GEN}/${id}.webp` : null + +/** Persona avatars are objects, not people, so they are never mistaken for a character. */ +export const PERSONA_AVATARS = ['persona-avatar-01', 'persona-avatar-02', 'persona-avatar-03', 'persona-avatar-04', 'persona-avatar-05', 'persona-avatar-06', 'persona-avatar-07', 'persona-avatar-08'] as const +export type PersonaAvatarId = (typeof PERSONA_AVATARS)[number] +export const isPersonaAvatar = (v: unknown): v is PersonaAvatarId => typeof v === 'string' && (PERSONA_AVATARS as readonly string[]).includes(v) +export const personaAvatarUrl = (id: unknown): string | null => isPersonaAvatar(id) ? `${GEN}/${id}.webp` : null +export const PERSONA_AVATAR_LABELS: Record = { + 'persona-avatar-01': {zh: '雨伞', en: 'Umbrella'}, 'persona-avatar-02': {zh: '灯笼', en: 'Lantern'}, 'persona-avatar-03': {zh: '茶杯', en: 'Teacup'}, + 'persona-avatar-04': {zh: '狐狸', en: 'Fox'}, 'persona-avatar-05': {zh: '睡着的猫', en: 'Sleeping cat'}, 'persona-avatar-06': {zh: '猫头鹰', en: 'Owl'}, + 'persona-avatar-07': {zh: '纸船', en: 'Paper boat'}, 'persona-avatar-08': {zh: '月亮', en: 'Crescent moon'}, +} + +export const BRAND_EMBLEM = `${GEN}/brand-emblem.png` diff --git a/everwith-claude-frontend/src/chat/Composer.tsx b/everwith-claude-frontend/src/chat/Composer.tsx new file mode 100644 index 00000000..383f9982 --- /dev/null +++ b/everwith-claude-frontend/src/chat/Composer.tsx @@ -0,0 +1,120 @@ +import {useEffect, useId, useLayoutEffect, useRef, useState} from 'react' +import type {FormEvent, RefObject} from 'react' +import {ArrowUp, Asterisk, Square, Zap} from 'lucide-react' +import {RichText} from './RichText' +import {useI18n} from '../app/i18n' + +/** + * Free input is always the primary entry. Enter sends, Shift+Enter breaks lines. + * IME safety: no send while composing (compositionstart/end, KeyboardEvent.isComposing, + * keyCode 229) and none for the trailing Enter some platforms deliver right after + * compositionend. NOTE: verified with synthetic events only; a real macOS Chinese IME + * pass still has to be done by a person (see ACCEPTANCE.md A04). + */ +export function Composer({value, onChange, onSubmit, onStop, busy = false, disabled = false, canSend = true, maxLength = 2000, label, placeholder, hints, inputRef, describedBy, selection, onSelection, extra}: { + value: string; onChange: (text: string) => void; onSubmit: (text: string) => void; onStop?: () => void + busy?: boolean; disabled?: boolean; canSend?: boolean; maxLength?: number; label: string; placeholder: string + hints?: {title: string; items: string[]; note: string} | null; inputRef?: RefObject; describedBy?: string + selection?: {start: number; end: number} | null; onSelection?: (s: {start: number; end: number}) => void; extra?: React.ReactNode +}) { + const {t} = useI18n() + const own = useRef(null), ref = inputRef ?? own + const composing = useRef(false), compositionEnded = useRef(-Infinity) + const pendingFocus = useRef<(() => void) | null>(null) + const [hintsOpen, setHintsOpen] = useState(false) + const [hintChoice, setHintChoice] = useState(null) + const hintId = useId() + useLayoutEffect(() => () => pendingFocus.current?.(), []) + // Auto-grow up to a limit, then scroll inside the box. + useLayoutEffect(() => { const e = ref.current; if (e) { e.style.height = '46px'; e.style.height = `${Math.min(196, Math.max(46, e.scrollHeight))}px` } }, [value, ref]) + // Restore the saved caret/selection once (per mount) without stealing focus. + const restored = useRef(false) + useEffect(() => { + const e = ref.current + if (!e || restored.current || !selection) return + restored.current = true + try { e.setSelectionRange(Math.min(selection.start, value.length), Math.min(selection.end, value.length)) } catch { /* ignore */ } + }, [selection, value.length, ref]) + const report = () => { const e = ref.current; if (e && onSelection) onSelection({start: e.selectionStart, end: e.selectionEnd}) } + const submit = (e?: FormEvent) => { e?.preventDefault(); if (!busy && !disabled && canSend && value.trim()) onSubmit(value) } + const insertStar = () => { + const e = ref.current; if (!e || disabled) return + const start = e.selectionStart, end = e.selectionEnd, selected = start !== end + if (value.length + (selected ? 2 : 1) > maxLength) return + pendingFocus.current?.() + const doc = e.ownerDocument, owner = doc.activeElement + const next = value.slice(0, start) + '*' + value.slice(start, end) + (selected ? '*' : '') + value.slice(end) + let retired = false, frame: number | undefined + // Never steal a newer focus choice, never write into an unmounted/hidden box. + const retire = () => { + if (retired) return; retired = true; if (frame !== undefined) cancelAnimationFrame(frame) + doc.removeEventListener('focusin', focus, true); doc.removeEventListener('pointerdown', retire, true) + doc.removeEventListener('keydown', retire, true); doc.removeEventListener('visibilitychange', visibility) + if (pendingFocus.current === retire) pendingFocus.current = null + } + const focus = (event: FocusEvent) => { if (event.target !== owner && event.target !== doc.body && event.target !== doc.documentElement) retire() } + const visibility = () => { if (doc.visibilityState === 'hidden') retire() } + pendingFocus.current = retire + doc.addEventListener('focusin', focus, true); doc.addEventListener('pointerdown', retire, true) + doc.addEventListener('keydown', retire, true); doc.addEventListener('visibilitychange', visibility) + onChange(next) + frame = requestAnimationFrame(() => { + if (retired) return + const current = doc.activeElement + const valid = ref.current === e && e.isConnected && !e.disabled && e.value === next && doc.visibilityState !== 'hidden' + && !e.closest('[hidden],[inert],[aria-hidden="true"],dialog:not([open])') && (current === owner || current === doc.body || current === doc.documentElement) + retire() + if (valid) { e.focus({preventScroll: true}); e.setSelectionRange(start + 1, selected ? end + 1 : start + 1); report() } + }) + } + const applyHint = (hint: string, mode: 'use' | 'keep') => { + setHintChoice(null); setHintsOpen(false) + if (mode === 'use') onChange(hint.slice(0, maxLength)) + requestAnimationFrame(() => ref.current?.focus()) + } + return ( +
+ {hints && hintsOpen && ( +
+
{hints.title}
+ {hintChoice ? ( +
+

{t('chat.draftExists')}

+
+ + +
+
+ ) : hints.items.map(h => ( + + ))} + {hints.note} +
+ )} +
+ {hints && } +
+