diff --git a/docs/assets/site.css b/docs/assets/site.css index 6d62f92..1e95bec 100644 --- a/docs/assets/site.css +++ b/docs/assets/site.css @@ -62,8 +62,17 @@ --ph-line: rgba(15, 18, 20, 0.1); --ph-in: #e9ecef; --ph-out: #2c8a52; - --ph-kb: #d9dde3; + + /* 键盘复刻色:取自 App 源码 ColorLibrary(brandAccent / aiTeal / recordRed)与原生键面色 */ + --kb-accent: #3aa05a; + --kb-teal: #2bafa4; + --kb-red: #ff3b30; + --ph-kb: #efeeec; --ph-key: #ffffff; + --ph-key-ink: #1c1c1e; + --ph-key-off: #d6d6d6; + --ph-track: rgba(0, 0, 0, 0.1); + --ph-glass: rgba(255, 255, 255, 0.66); } html[data-theme="dark"] { @@ -88,8 +97,12 @@ html[data-theme="dark"] { --ph-line: rgba(255, 255, 255, 0.1); --ph-in: #23262b; --ph-out: #2f9a5b; - --ph-kb: #1a1d21; - --ph-key: #34383e; + --ph-kb: #232324; + --ph-key: #525252; + --ph-key-ink: #ffffff; + --ph-key-off: #3b3b3b; + --ph-track: #1a1a1a; + --ph-glass: rgba(255, 255, 255, 0.09); } /* -------------------------------------------------------------------------- @@ -748,7 +761,7 @@ html[data-lang="zh"] h3 { max-width: 92%; align-items: center; gap: 14px; - padding: 9px 22px 9px 9px; + padding: 9px 22px 9px 10px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 999px; background: rgba(14, 16, 19, 0.74); @@ -759,69 +772,115 @@ html[data-lang="zh"] h3 { translate: -50% 0; } -.capsule-orb { +/* + * 麦克风胶囊:与键盘真实组件同色同形(156×60pt 的胶囊按比例缩小)。 + * 待机 = 品牌绿 + 麦克风;录音 = 红色 + 7 条声波;长按问 AI = 青色 + 声波; + * 处理中 = 半透明红 + 转圈;插入成功 = 品牌绿 + 对勾。状态类由 site.js 切换。 + */ +.capsule-mic { position: relative; display: grid; - width: 42px; - height: 42px; + width: 78px; + height: 32px; flex: none; place-items: center; - border-radius: 50%; - background: radial-gradient(circle at 35% 30%, #a8e6c1, var(--stage-accent) 55%, #3c9c63); - color: #0c0e12; + border-radius: 999px; + background: var(--kb-accent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px -6px color-mix(in srgb, var(--kb-accent) 80%, transparent); + color: #fff; + transition: background-color 0.35s ease, box-shadow 0.35s ease, transform 0.5s var(--ease-spring); } -.capsule-orb .i { - width: 19px; - height: 19px; - stroke-width: 2; +.capsule-mic > * { + grid-area: 1 / 1; + transition: opacity 0.25s ease, transform 0.45s var(--ease-spring); } -/* 录音态:橙绿色涟漪从按钮向外扩散 */ -.capsule-orb::before, -.capsule-orb::after { - position: absolute; - inset: 0; - border: 1.5px solid var(--stage-accent); - border-radius: 50%; - content: ""; +.cap-mic { + width: 17px; + height: 17px; + stroke-width: 2.2; +} + +.cap-check { + width: 18px; + height: 18px; + stroke-width: 3; +} + +.capsule-wave, +.capsule-spin, +.cap-check { opacity: 0; + transform: scale(0.6); } -.voice-capsule.is-listening .capsule-orb::before, -.voice-capsule.is-listening .capsule-orb::after { - animation: orb-ripple 1.8s cubic-bezier(0.2, 0.6, 0.3, 1) infinite; +.voice-capsule.is-rec .capsule-mic { + background: var(--kb-red); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 22px -6px color-mix(in srgb, var(--kb-red) 85%, transparent); } -.voice-capsule.is-listening .capsule-orb::after { - animation-delay: 0.9s; +.voice-capsule.is-ai .capsule-mic { + background: var(--kb-teal); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 22px -6px color-mix(in srgb, var(--kb-teal) 85%, transparent); + transform: scale(1.04); } -@keyframes orb-ripple { - 0% { opacity: 0.9; transform: scale(1); } - 100% { opacity: 0; transform: scale(1.9); } +.voice-capsule.is-busy .capsule-mic { + background: color-mix(in srgb, var(--kb-red) 72%, #2a2d31); +} + +.voice-capsule:is(.is-rec, .is-ai, .is-busy, .is-done) .cap-mic { + opacity: 0; + transform: scale(0.6); +} + +.voice-capsule:is(.is-rec, .is-ai) .capsule-wave, +.voice-capsule.is-busy .capsule-spin, +.voice-capsule.is-done .cap-check { + opacity: 1; + transform: none; } /* 声波条:site.js 每帧写 scaleY,只动 transform,不触发布局 */ .capsule-wave, -.kb-wave { +.mic-wave { display: flex; align-items: center; } .capsule-wave { - height: 28px; - gap: 3px; + height: 16px; + gap: 2.5px; } .capsule-wave i { - width: 3px; + width: 2.5px; height: 100%; - border-radius: 3px; - background: linear-gradient(180deg, #c9f3da, var(--stage-accent)); + border-radius: 2px; + background: #ffc7c7; transform: scaleY(0.14); } +.voice-capsule.is-ai .capsule-wave i { + background: #fff; +} + +/* 系统风格的转圈:处理中状态 */ +.capsule-spin, +.mic-spin { + width: 15px; + height: 15px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { rotate: 1turn; } +} + .capsule-body { display: flex; min-width: min(400px, 52vw); @@ -844,9 +903,10 @@ html[data-lang="zh"] h3 { transition: background 0.3s ease, color 0.3s ease; } -.capsule-tag.is-agent { - background: var(--stage-accent); - color: #0c0e12; +/* 标签颜色跟随胶囊状态:问 AI 用青色,与麦克风胶囊呼应 */ +.voice-capsule:is(.is-ai, .is-ai-busy) .capsule-tag { + background: color-mix(in srgb, var(--kb-teal) 22%, transparent); + color: #7fded4; } .capsule-line { @@ -1219,7 +1279,7 @@ h3 { /* -------------------------------------------------------------------------- * 9. 滚动叙事(三幕剧) - * 状态类 s1…s8 由 site.js 依滚动进度累加到 .story 上; + * 消息类 m1/o1/m2/c2/o2/m3/c3/ev 与键盘 data-* 状态由 site.js 依滚动进度写到 .story 上; * 只有 .story.is-live(脚本已接管)时才进入「未发生 → 隐藏」的初始态, * 无 JavaScript 时手机直接展示最终画面。 * -------------------------------------------------------------------------- */ @@ -1351,7 +1411,7 @@ h3 { transition: opacity 0.6s ease, transform 0.8s var(--ease-out); } -.story.s2:not(.s3) .story-device::before { +.story:is([data-mic="rec"], [data-mic="busy"]) .story-device::before { opacity: 1; transform: scale(1.08); } @@ -1461,22 +1521,27 @@ h3 { font-size: 2.7cqw; } +/* 底部对齐:新消息出现时把旧消息向上推,和真实聊天一样 */ .chat-body { display: flex; + overflow: hidden; min-height: 0; flex: 1; flex-direction: column; - gap: 2.6cqw; - padding: 4cqw 4.4cqw; + justify-content: flex-end; + padding: 1cqw 4.4cqw 3cqw; } .bubble { position: relative; + flex: none; max-width: 76%; - padding: 2.3cqw 3.6cqw; + margin-top: 2.4cqw; + padding: 2.1cqw 3.4cqw; border-radius: 5cqw; - font-size: 3.85cqw; - transition: opacity 0.35s ease, transform 0.7s var(--ease-spring), box-shadow 0.4s ease; + font-size: 3.6cqw; + transition: opacity 0.35s ease, transform 0.7s var(--ease-spring), box-shadow 0.4s ease, + max-height 0.5s var(--ease-out), margin 0.5s var(--ease-out), padding 0.5s var(--ease-out); } .bubble.in { @@ -1494,21 +1559,32 @@ h3 { transform-origin: 100% 100%; } +/* 未出现的气泡不占位(高度收为 0),出现时弹簧放大 */ .story.is-live .bubble { + max-height: 0; + margin-top: 0; + padding-block: 0; opacity: 0; transform: translateY(3cqw) scale(0.6); } -.story.is-live.s1 .b-ask, -.story.is-live.s4 .b-sent, -.story.is-live.s5 .b-addr { +.story.is-live.m1 .b-m1, +.story.is-live.o1 .b-o1, +.story.is-live.m2 .b-m2, +.story.is-live.o2 .b-o2, +.story.is-live.m3 .b-m3 { + max-height: 40cqw; + margin-top: 2.4cqw; + padding-block: 2.1cqw; opacity: 1; transform: none; } -/* 第三幕:复制地址 → 气泡高亮 + 「已复制」提示 */ -.story.s6 .b-addr { - box-shadow: 0 0 0 0.8cqw color-mix(in srgb, var(--ph-out) 40%, transparent); +/* 长按复制:气泡描边高亮 + 「已复制」提示 */ +.story.c2 .b-m2, +.story.c3 .b-m3, +.skills-demo.is-copied .sd-msg { + box-shadow: 0 0 0 0.8cqw color-mix(in srgb, var(--kb-accent) 40%, transparent); } .copy-toast { @@ -1535,7 +1611,9 @@ h3 { stroke-width: 2.4; } -.story.s6:not(.s7) .copy-toast { +.story.c2 .b-m2 .copy-toast, +.story.c3 .b-m3 .copy-toast, +.skills-demo.is-copied .copy-toast { opacity: 1; transform: none; } @@ -1564,7 +1642,7 @@ h3 { transform: translateY(-130%) scale(0.92); } -.story.is-live.s8 .event-banner { +.story.is-live.ev .event-banner { opacity: 1; transform: none; } @@ -1631,7 +1709,7 @@ h3 { transform: scale(0); } -.story.is-live.s8 .event-check { +.story.is-live.ev .event-check { transform: none; } @@ -1685,7 +1763,7 @@ h3 { visibility: hidden; } -.story.s2:not(.s4) .compose .caret { +.story.has-text .compose .caret { visibility: visible; } @@ -1712,215 +1790,423 @@ h3 { transform: scale(1.05); } -/* 键盘:顶部条在「聆听 / 语气 / 技能」三种状态间切换 */ +/* + * 键盘:按 App 真实的「AI 助手」界面复刻(AIKeyboardView)。 + * 尺寸以 cqw 表示,等比映射原生 pt 尺寸(顶栏 44pt、麦克风 156×60pt、圆键 48pt)。 + * 所有状态都由祖先元素上的 data-* 属性驱动,故事区与技能区共用同一套样式: + * data-bar 顶栏:tabs 常规 / cancel 录音中 / paste AI 结果 / skills 复制后 + * data-mic 麦克风:idle / rec 听写 / ai 问 AI / busy 处理中 / done 已插入 + * data-ctx 上下文区:hint / live / think / extract / tip / none + * data-surface 主体:main 常规 / variants 回复候选 + * data-pick 被点按的元素:0-2 回复卡片 / event 日程胶囊 + * data-undo 插入后出现撤销与改写按钮;data-action 回车键可用 + */ .kb { display: flex; flex: none; flex-direction: column; - gap: 2.4cqw; - padding: 2.6cqw 2.6cqw 6.4cqw; + padding: 1cqw 3cqw 7cqw; background: var(--ph-kb); + color: var(--ph-key-ink); + transition: background 0.4s ease; +} + +.kb .i { + stroke-width: 2; +} + +.kb-top { + position: relative; + height: 10.5cqw; + flex: none; } .kb-bar { - position: relative; - height: 8.6cqw; -} - -.kb-status, -.kb-tones, -.kb-skills { position: absolute; inset: 0; display: flex; align-items: center; + gap: 1.6cqw; opacity: 0; pointer-events: none; - transform: translateY(2cqw); - transition: opacity 0.35s ease, transform 0.6s var(--ease-spring); + transform: translateY(1.5cqw); + transition: opacity 0.3s ease, transform 0.55s var(--ease-spring); } -.story.s2:not(.s3) .kb-status, -.story.s3:not(.s4) .kb-tones, -.story.s7 .kb-skills, -.story:not(.is-live) .kb-skills { +[data-bar="tabs"] .kb-bar-tabs, +[data-bar="cancel"] .kb-bar-cancel, +[data-bar="paste"] .kb-bar-paste, +[data-bar="skills"] .kb-bar-skills { opacity: 1; transform: none; } -.kb-status { - justify-content: center; - gap: 2cqw; - color: var(--ph-out); - font-size: 3.4cqw; - font-weight: 650; +.kb-bar-tabs, +.kb-bar-paste { + justify-content: space-between; } -.kb-status i { - width: 2cqw; - height: 2cqw; +.kb-bar-cancel { + justify-content: flex-end; +} + +/* OSG 标志:两实一空的三个圆点 */ +.kb-logo { + display: flex; + gap: 0.8cqw; +} + +.kb-logo i { + width: 3.4cqw; + height: 3.4cqw; border-radius: 50%; background: currentColor; - animation: caret-blink 1s steps(1) infinite; } -.kb-tones { - gap: 1.8cqw; +.kb-logo i:nth-child(2) { + background: transparent; + box-shadow: inset 0 0 0 0.45cqw currentColor; } -.kb-tones > span, -.kb-skills > span { - display: inline-flex; - align-items: center; - gap: 1cqw; - padding: 1.5cqw 3cqw; +/* AI / 中文 / EN 分段切换:固定在键盘水平中心,与真实界面一致 */ +.kb-seg { + position: absolute; + left: 50%; + display: flex; + gap: 0.5cqw; + padding: 0.5cqw; border-radius: 99px; + background: var(--ph-track); + translate: -50% 0; +} + +.kb-seg > span { + display: grid; + width: 11.4cqw; + height: 7.2cqw; + place-items: center; + border-radius: 99px; + color: color-mix(in srgb, var(--ph-key-ink) 55%, transparent); + font-size: 2.85cqw; + font-weight: 500; +} + +.kb-seg > .is-on { background: var(--ph-key); - font-size: 3.2cqw; - font-weight: 600; - transition: background 0.3s ease, color 0.3s ease, transform 0.6s var(--ease-spring), - opacity 0.3s ease; + box-shadow: 0 0 0 0.25cqw var(--ph-line); + color: var(--ph-key-ink); } -.story.tone-0 [data-tone="0"], -.story.tone-1 [data-tone="1"], -.story.tone-2 [data-tone="2"] { - background: var(--ph-out); +.kb-seg .i { + width: 3.7cqw; + height: 3.7cqw; +} + +.kb-chips { + display: flex; + gap: 1.4cqw; +} + +.kb-chip { + display: grid; + width: 8.2cqw; + height: 8.2cqw; + flex: none; + place-items: center; + border-radius: 50%; + background: var(--ph-key); + box-shadow: 0 0 0 0.25cqw var(--ph-line); + color: color-mix(in srgb, var(--ph-key-ink) 60%, transparent); +} + +.kb-chip .i { + width: 3.8cqw; + height: 3.8cqw; +} + +/* 玻璃胶囊:粘贴与技能 */ +.kb-cap { + display: inline-flex; + height: 7.2cqw; + flex: none; + align-items: center; + padding: 0 3cqw; + border-radius: 99px; + background: var(--ph-glass); + box-shadow: 0 0.6cqw 1.6cqw -0.6cqw rgba(0, 0, 0, 0.2), inset 0 0 0 0.25cqw var(--ph-line); + font-size: 3cqw; + font-weight: 650; + white-space: nowrap; + transition: background 0.25s ease, color 0.25s ease, transform 0.45s var(--ease-spring); +} + +.kb-caps { + display: flex; + overflow: hidden; + min-width: 0; + flex: 1; + gap: 1.4cqw; + padding: 1cqw 0; + -webkit-mask-image: linear-gradient(90deg, #000 86%, transparent); + mask-image: linear-gradient(90deg, #000 86%, transparent); +} + +/* 复制后技能胶囊依次弹出:模拟「按相关度挑出能用的技能」 */ +[data-bar="skills"] .kb-caps .kb-cap { + animation: cap-in 0.55s var(--ease-spring) both; +} + +.kb-caps .kb-cap:nth-child(2) { animation-delay: 0.06s; } +.kb-caps .kb-cap:nth-child(3) { animation-delay: 0.12s; } +.kb-caps .kb-cap:nth-child(4) { animation-delay: 0.18s; } +.kb-caps .kb-cap:nth-child(5) { animation-delay: 0.24s; } + +@keyframes cap-in { + from { opacity: 0; transform: translateY(2cqw) scale(0.7); } + to { opacity: 1; transform: none; } +} + +[data-pick="event"] .cap-event { + background: var(--kb-accent); color: #fff; - transform: scale(1.07); + transform: scale(0.94); } -.kb-tones em { - margin-left: auto; - color: var(--ph-muted); - font-size: 2.6cqw; - font-style: normal; -} - -.kb-skills { - gap: 1.6cqw; -} - -.kb-skills .i { - width: 3.6cqw; - height: 3.6cqw; -} - -/* 技能芯片依次弹出,模拟「从技能目录里挑出相关操作」 */ -.story.is-live .kb-skills > span { - opacity: 0; - transform: translateY(3cqw) scale(0.7); -} - -.story.is-live.s7 .kb-skills > span { - opacity: 1; - transform: none; -} - -.kb-skills > span:nth-child(2) { transition-delay: 0.06s; } -.kb-skills > span:nth-child(3) { transition-delay: 0.12s; } -.kb-skills > span:nth-child(4) { transition-delay: 0.18s; } - -.story.is-live.s8 .kb-skills .skill-event, -.story:not(.is-live) .kb-skills .skill-event { - background: var(--ph-out); - color: #fff; - transform: scale(1.1); - transition-delay: 0s; +/* 主体:上下文区 + 两排操作键;回复候选出现时整体让位 */ +.kb-stage { + position: relative; + height: 51cqw; + flex: none; } .kb-main { + position: absolute; + inset: 0; display: flex; - height: 21cqw; + flex-direction: column; + transition: opacity 0.3s ease, transform 0.5s var(--ease-out); +} + +[data-surface="variants"] .kb-main { + opacity: 0; + transform: scale(0.97); +} + +.kb-ctx { + position: relative; + flex: 1; +} + +.ctx { + position: absolute; + top: 50%; + left: 50%; + max-width: 88%; + font-size: 3.3cqw; + opacity: 0; + text-align: center; + transform: translate(-50%, -30%); + transition: opacity 0.3s ease, transform 0.5s var(--ease-out); + white-space: nowrap; +} + +[data-ctx="hint"] .ctx-hint, +[data-ctx="live"] .ctx-live, +[data-ctx="think"] .ctx-think, +[data-ctx="extract"] .ctx-extract, +[data-ctx="tip"] .ctx-tip { + opacity: 1; + transform: translate(-50%, -50%); +} + +.ctx-hint { + padding: 1.2cqw 3.2cqw; + border-radius: 99px; + box-shadow: inset 0 0 0 0.3cqw color-mix(in srgb, var(--ph-key-ink) 18%, transparent); + color: color-mix(in srgb, var(--ph-key-ink) 55%, transparent); + font-weight: 500; +} + +/* 实时转写:听写时边说边出字 */ +.ctx-live { + width: 88%; + font-size: 3.7cqw; + line-height: 1.35; + white-space: normal; +} + +.ctx-think, +.ctx-extract { + color: color-mix(in srgb, var(--ph-key-ink) 55%, transparent); + font-size: 3.6cqw; +} + +.ctx-tip { + padding: 1.6cqw 3.6cqw; + border-radius: 99px; + background: var(--ph-glass); + box-shadow: 0 1cqw 3cqw -1cqw rgba(0, 0, 0, 0.22), inset 0 0 0 0.25cqw var(--ph-line); + font-size: 3.6cqw; +} + +.kb-row { + display: flex; + height: 14.3cqw; + flex: none; align-items: center; - justify-content: center; - gap: 4cqw; + justify-content: space-between; + padding: 0 2cqw; } -.kb-wave { - height: 12cqw; - gap: 1.2cqw; +.kb-row-low { + height: 11.4cqw; + margin-top: 5.2cqw; } -.kb-wave i { - width: 1.1cqw; - height: 100%; - border-radius: 1cqw; - background: var(--ph-out); - opacity: 0.75; - transform: scaleY(0.1); +.kb-circle { + display: grid; + width: 11.4cqw; + height: 11.4cqw; + flex: none; + place-items: center; + border-radius: 50%; + background: var(--ph-key); + box-shadow: 0 1cqw 2.4cqw -1cqw rgba(0, 0, 0, 0.25), inset 0 0 0 0.25cqw var(--ph-line); + transition: opacity 0.35s ease, scale 0.5s var(--ease-spring); +} + +.kb-circle .i { + width: 5cqw; + height: 5cqw; +} + +/* 撤销与口述改写只在刚插入过文字后出现 */ +.kb-undo, +.kb-edit { + opacity: 0; + scale: 0.7; +} + +[data-undo="1"] .kb-undo, +[data-undo="1"] .kb-edit { + opacity: 1; + scale: 1; } .kb-mic { position: relative; display: grid; - width: 17cqw; - height: 17cqw; + width: 37cqw; + height: 14.3cqw; + flex: none; place-items: center; - border-radius: 50%; - background: var(--ph-out); - box-shadow: 0 2cqw 6cqw -1cqw color-mix(in srgb, var(--ph-out) 70%, transparent); + border-radius: 99px; + background: var(--kb-accent); + box-shadow: inset 0 0.3cqw 0 rgba(255, 255, 255, 0.3), + 0 1.6cqw 4cqw -1.6cqw color-mix(in srgb, var(--kb-accent) 85%, transparent); color: #fff; - transition: transform 0.6s var(--ease-spring); + transition: background-color 0.35s ease, box-shadow 0.35s ease, transform 0.5s var(--ease-spring); } -.kb-mic .i { - width: 7.4cqw; - height: 7.4cqw; - stroke-width: 2; +.kb-mic > * { + grid-area: 1 / 1; + transition: opacity 0.25s ease, transform 0.45s var(--ease-spring); } -.kb-mic::before, -.kb-mic::after { - position: absolute; - inset: 0; - border: 0.5cqw solid var(--ph-out); - border-radius: 50%; - content: ""; +.mic-icon { + width: 6.6cqw; + height: 6.6cqw; +} + +.kb .mic-check { + width: 7cqw; + height: 7cqw; + stroke-width: 3; +} + +.mic-wave { + height: 6.2cqw; + gap: 1cqw; +} + +.mic-wave i { + width: 1cqw; + height: 100%; + border-radius: 1cqw; + background: #ffc7c7; + transform: scaleY(0.14); +} + +.kb .mic-spin { + width: 5.2cqw; + height: 5.2cqw; + border-width: 0.7cqw; +} + +.mic-wave, +.mic-spin, +.mic-check { opacity: 0; + transform: scale(0.6); } -.story.s2:not(.s3) .kb-mic { - transform: scale(1.08); +[data-mic="rec"] .kb-mic { + background: var(--kb-red); + box-shadow: inset 0 0.3cqw 0 rgba(255, 255, 255, 0.3), + 0 1.6cqw 4.4cqw -1.6cqw color-mix(in srgb, var(--kb-red) 85%, transparent); } -.story.s2:not(.s3) .kb-mic::before, -.story.s2:not(.s3) .kb-mic::after { - animation: orb-ripple 1.6s cubic-bezier(0.2, 0.6, 0.3, 1) infinite; +[data-mic="ai"] .kb-mic { + background: var(--kb-teal); + transform: scale(1.03); } -.story.s2:not(.s3) .kb-mic::after { - animation-delay: 0.8s; +[data-mic="ai"] .mic-wave i { + background: #fff; } -.kb-keys { +[data-mic="busy"] .kb-mic { + background: color-mix(in srgb, var(--kb-red) 72%, var(--ph-kb)); + box-shadow: none; +} + +:is([data-mic="rec"], [data-mic="ai"], [data-mic="busy"], [data-mic="done"]) .mic-icon { + opacity: 0; + transform: scale(0.6); +} + +:is([data-mic="rec"], [data-mic="ai"]) .mic-wave, +[data-mic="busy"] .mic-spin, +[data-mic="done"] .mic-check { + opacity: 1; + transform: none; +} + +/* 回车 / 发送:无可发送内容时为浅灰禁用态 */ +.kb-action { display: grid; - gap: 1.8cqw; - grid-template-columns: 1fr 3.4fr 1.5fr; -} - -.kb-key { - display: grid; - height: 10cqw; + width: 31.4cqw; + height: 11.4cqw; + flex: none; place-items: center; - border-radius: 2.4cqw; + border-radius: 99px; + background: var(--ph-key-off); + color: color-mix(in srgb, var(--ph-key-ink) 45%, transparent); + transition: background 0.3s ease, color 0.3s ease, transform 0.45s var(--ease-spring); +} + +.kb-action .i { + width: 5.4cqw; + height: 5.4cqw; +} + +[data-action="on"] .kb-action { background: var(--ph-key); - box-shadow: 0 0.4cqw 0 rgba(0, 0, 0, 0.16); - font-size: 3.4cqw; - transition: transform 0.4s var(--ease-spring), background 0.3s ease; + box-shadow: 0 1cqw 2.4cqw -1cqw rgba(0, 0, 0, 0.25), inset 0 0 0 0.25cqw var(--ph-line); + color: var(--ph-key-ink); } -.kb-key .i { - width: 4.8cqw; - height: 4.8cqw; -} - -.kb-return { - background: var(--ph-out); - color: #fff; -} - -.story.s4:not(.s5) .kb-return { +.story.press-send .kb-action { animation: key-press 0.5s var(--ease-out); } @@ -1928,6 +2214,151 @@ h3 { 30% { transform: scale(0.9); } } +/* 回复候选:三张玻璃卡片(普通 / 正式 / 轻松趣味),自下而上依次落定 */ +.kb-variants { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 1.8cqw; + padding: 1.2cqw 0.6cqw 0; + pointer-events: none; +} + +.kb-card { + display: flex; + align-items: flex-start; + gap: 2.6cqw; + padding: 2cqw 3cqw; + border-radius: 3.6cqw; + background: var(--ph-glass); + box-shadow: 0 0.8cqw 2.4cqw -1.2cqw rgba(0, 0, 0, 0.2), inset 0 0 0 0.25cqw var(--ph-line); + opacity: 0; + transform: translateY(5cqw) scale(0.96); + transition: opacity 0.35s ease, transform 0.6s var(--ease-spring), box-shadow 0.3s ease; +} + +.kb-card:nth-child(2) { transition-delay: 0.07s; } +.kb-card:nth-child(3) { transition-delay: 0.14s; } + +[data-surface="variants"] .kb-card { + opacity: 1; + transform: none; +} + +.kb-card .i-solid { + width: 4.6cqw; + height: 4.6cqw; + flex: none; + margin-top: 0.4cqw; + fill: currentColor; + stroke: none; +} + +.kb-card > span { + display: grid; + gap: 0.3cqw; + line-height: 1.32; +} + +.kb-card small { + color: color-mix(in srgb, var(--ph-key-ink) 55%, transparent); + font-size: 2.7cqw; +} + +.kb-card b { + font-size: 3.2cqw; + font-weight: 500; +} + +[data-pick="0"] .kb-card[data-card="0"], +[data-pick="1"] .kb-card[data-card="1"], +[data-pick="2"] .kb-card[data-card="2"] { + box-shadow: 0 0 0 0.7cqw color-mix(in srgb, var(--kb-accent) 50%, transparent); + transform: scale(0.97); + transition-delay: 0s; +} + +/* -------------------------------------------------------------------------- + * 技能区演示:复用键盘复刻组件,独立的容器查询让它按自身宽度缩放 + * -------------------------------------------------------------------------- */ +.skills-frame { + display: grid; + place-items: center; + padding: clamp(18px, 3vw, 34px); + background: radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--accent) 14%, var(--surface)), var(--surface-soft)); +} + +.skills-demo { + display: flex; + overflow: hidden; + width: min(100%, 430px); + flex-direction: column; + border-radius: 22px; + background: var(--ph-bg); + box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.45), inset 0 0 0 1px var(--ph-line); + color: var(--ph-ink); + container-type: inline-size; + text-align: left; +} + +/* 消息来源区:被复制的那条消息,最后被「已添加到日历」横幅替换 */ +.sd-source { + position: relative; + display: grid; + min-height: 34cqw; + align-items: center; + padding: 7cqw 5cqw; +} + +.sd-msg { + position: relative; + justify-self: start; + max-width: 82%; + padding: 2.4cqw 3.8cqw; + border-radius: 5cqw 5cqw 5cqw 1.4cqw; + background: var(--ph-in); + font-size: 3.7cqw; + line-height: 1.4; + transition: box-shadow 0.4s ease, opacity 0.4s ease, transform 0.6s var(--ease-out); +} + +.skills-demo .event-banner { + top: 50%; + right: 5cqw; + left: 5cqw; + translate: 0 -50%; +} + +.skills-demo .event-banner, +.skills-demo .event-check { + opacity: 0; + transform: translateY(-40%) scale(0.92); +} + +.skills-demo .event-check { + transform: scale(0); +} + +.skills-demo.is-done .event-banner { + opacity: 1; + transform: none; +} + +.skills-demo.is-done .event-check { + opacity: 1; + transform: none; +} + +.skills-demo.is-done .sd-msg { + opacity: 0; + transform: translateY(6cqw) scale(0.94); +} + +.skills-demo .kb { + padding-bottom: 5cqw; +} + /* -------------------------------------------------------------------------- * 10. 数字带 * -------------------------------------------------------------------------- */ @@ -3068,13 +3499,9 @@ html.vt-theme::view-transition-new(root) { padding: 6px 14px 6px 6px; } - .capsule-orb { - width: 34px; - height: 34px; - } - - .capsule-wave { - display: none; + .capsule-mic { + width: 62px; + height: 28px; } .capsule-body { diff --git a/docs/assets/site.js b/docs/assets/site.js index 004c5d0..8294ba9 100644 --- a/docs/assets/site.js +++ b/docs/assets/site.js @@ -368,19 +368,23 @@ /* ---------------- Hero 语音胶囊 ---------------- */ + // 复刻键盘真实的麦克风状态流:轻点听写(红色声波 → 打勾)、长按问 AI(青色声波 → 转圈 → 回答)、 + // 复制消息后的嘴替回复(转圈 → 回复)。文案取自 App 的实际界面字符串与演示素材。 const CAPSULE_SCRIPT = { zh: [ - { tag: "听写", text: "帮我订周五晚上七点的位子,四个人" }, - { tag: "嘴替 · 轻松", text: "这周有点累,先不去啦,下次我请!" }, - { tag: "替你做", text: "已建日程:周五 19:00 · 四人晚餐", agent: true } + { tag: "轻点听写", mode: "rec", text: "帮我订周五晚上七点的位子,四个人" }, + { tag: "长按问 AI", mode: "ai", text: "周末去哪儿玩比较合适?", busy: "AI 正在思考…", answer: "可以去近郊走走:上午逛古镇,下午找家咖啡馆。" }, + { tag: "复制 · 嘴替回复", busy: "AI 正在思考…", answer: "好的,我整理一下预算版,明天上午发你。" } ], en: [ - { tag: "Dictate", text: "Book a table for four, Friday at seven" }, - { tag: "Your voice", text: "Bit wiped this week — rain check? Next one’s on me!" }, - { tag: "Done for you", text: "Event added: Fri 7:00 PM · Dinner for four", agent: true } + { tag: "Tap to dictate", mode: "rec", text: "Book a table for four, Friday at seven" }, + { tag: "Hold to ask AI", mode: "ai", text: "Where should we go this weekend?", busy: "AI is thinking…", answer: "Try a day trip: an old town in the morning, a café in the afternoon." }, + { tag: "Copy · Reply in your voice", busy: "AI is thinking…", answer: "Sure, I’ll put the budget version together and send it over tomorrow morning." } ] }; + const CAPSULE_STATES = ["is-rec", "is-ai", "is-busy", "is-ai-busy", "is-done"]; + const hero = document.getElementById("top"); const capsule = document.querySelector(".voice-capsule"); const capsuleTag = capsule?.querySelector(".capsule-tag"); @@ -400,6 +404,24 @@ return run === capsuleRun; } + function setCapsuleState(...states) { + CAPSULE_STATES.forEach((name) => capsule.classList.toggle(name, states.includes(name))); + const listening = states.includes("is-rec") || states.includes("is-ai"); + if (capsuleWave) capsuleWave.level = listening ? 1 : 0; + if (listening) wakeWaves(); + } + + // 逐字写入;返回 false 表示本轮已被新一轮(如切换语言)取代 + async function typeLine(run, text, perChar) { + const characters = Array.from(text); + for (let i = 1; i <= characters.length; i += 1) { + if (!(await whenActive(run))) return false; + capsuleLine.textContent = characters.slice(0, i).join(""); + await wait(perChar()); + } + return true; + } + async function playCapsule() { if (!capsule || !capsuleLine || !capsuleTag) return; const run = ++capsuleRun; @@ -407,39 +429,50 @@ const script = CAPSULE_SCRIPT[lang]; if (reduceMotion) { + setCapsuleState(); capsuleTag.textContent = script[0].tag; capsuleLine.textContent = script[0].text; return; } + const base = lang === "zh" ? 72 : 34; + const speech = () => base + Math.random() * base * 0.8; let index = 0; while (run === capsuleRun) { const item = script[index % script.length]; + setCapsuleState(); capsuleTag.textContent = item.tag; - capsuleTag.classList.toggle("is-agent", Boolean(item.agent)); capsuleLine.textContent = ""; - await wait(260); + await wait(650); - // 听写与嘴替模拟「边说边出字」,Agentic 结果直接以完成态弹出 - const listening = !item.agent; - capsule.classList.toggle("is-listening", listening); - if (capsuleWave) capsuleWave.level = listening ? 1 : 0.15; - wakeWaves(); - - const characters = Array.from(item.text); - const base = lang === "zh" ? 70 : 34; - for (let i = 1; i <= characters.length; i += 1) { - if (!(await whenActive(run))) return; - capsuleLine.textContent = characters.slice(0, i).join(""); - await wait(item.agent ? 14 : base + Math.random() * base * 0.8); + // 1. 说话:红色(听写)或青色(问 AI)胶囊 + 声波,边说边出字 + if (item.text) { + setCapsuleState(item.mode === "ai" ? "is-ai" : "is-rec"); + if (!(await typeLine(run, item.text, speech))) return; + await wait(420); } - capsule.classList.remove("is-listening"); - if (capsuleWave) capsuleWave.level = 0.12; - await wait(item.agent ? 2400 : 1900); + // 2. 处理中:胶囊转圈,显示真实界面的状态文案 + if (item.busy) { + setCapsuleState("is-busy", item.mode === "ai" ? "is-ai-busy" : ""); + capsuleLine.textContent = item.busy; + await wait(1300); + if (!(await whenActive(run))) return; + } + + // 3. 结果:回答快速流出 + if (item.answer) { + capsuleLine.textContent = ""; + if (!(await typeLine(run, item.answer, () => 16))) return; + } + + // 4. 插入成功:胶囊打勾 + setCapsuleState("is-done"); + await wait(2100); if (!(await whenActive(run))) return; - // 快速回删,像是把这一句交给下一个能力 + // 快速回删,交给下一个能力 + const characters = Array.from(capsuleLine.textContent); for (let i = characters.length; i >= 0; i -= 2) { if (run !== capsuleRun) return; capsuleLine.textContent = characters.slice(0, i).join(""); @@ -451,43 +484,69 @@ languageHooks.push(() => playCapsule()); + /* ---------------- 键盘复刻:共用状态写入 ---------------- */ + + const KB_DEFAULTS = { bar: "tabs", mic: "idle", ctx: "hint", surface: "main", pick: "", undo: "0", action: "off" }; + + // 把键盘状态写到容器的 data-* 上(只写变化的值,避免无谓的样式重算) + function applyKeyboardState(element, state) { + Object.keys(KB_DEFAULTS).forEach((key) => { + const value = state[key] ?? KB_DEFAULTS[key]; + if (element.dataset[key] !== value) element.dataset[key] = value; + }); + } + /* ---------------- 三幕滚动叙事 ---------------- */ + // 与气泡文案保持一致:听写结果、选中的「普通」回复 const STORY_TEXT = { zh: { - draft: "可以啊 周五七点 位子我来订", - tones: [ - "可以,周五七点,位子我来订。", - "好的,周五晚七点见,座位由我来预订。", - "好呀!周五七点不见不散,位子包在我身上~" - ] + dictation: "收到了,我下午看完给你反馈。", + reply: "好的,我整理一下预算版,明天上午发你。" }, en: { - draft: "sure friday at seven ill book a table", - tones: [ - "Sure — Friday at seven. I’ll book a table.", - "Sounds good. Friday at 7 p.m.; I’ll make the reservation.", - "Yes! Friday at 7 — leave the table to me." - ] + dictation: "Got it — I’ll review it this afternoon and get back to you.", + reply: "Sure, I’ll put the budget version together and send it over tomorrow morning." } }; - // 进度阈值(0 → 1):s1 好友来信 · s2 开始聆听 · s3 嘴替改写 · s4 发送 - // s5 好友回地址 · s6 复制 · s7 技能弹出 · s8 写入日历 - const STORY_STEPS = [0.03, 0.08, 0.31, 0.585, 0.66, 0.735, 0.805, 0.885]; - const TYPE_START = 0.08; - const TYPE_END = 0.29; - const TONE_STEPS = [0.36, 0.44, 0.515]; - const ACT_RANGES = [[0, 0.31], [0.31, 0.66], [0.66, 1]]; + // 键盘时间轴(滚动进度 0 → 1),每段是完整状态,取最后一个 at ≤ p 的段 + // 第一幕 听写:录音 → 打勾插入 → 发送 + // 第二幕 嘴替:复制消息 → AI 思考 → 三条回复 → 点「普通」插入 → 发送 + // 第三幕 Agentic:复制会议通知 → 顶栏技能 → 点「日程」→ 提取 → 写入日历 + const STORY_PHASES = [ + { at: 0 }, + { at: 0.065, bar: "cancel", mic: "rec", ctx: "live" }, + { at: 0.235, mic: "done", ctx: "none", undo: "1", action: "on", compose: "dictation" }, + { at: 0.27, undo: "1", action: "on", compose: "dictation", send: true }, + { at: 0.29 }, + { at: 0.375, bar: "paste", mic: "busy", ctx: "think" }, + { at: 0.44, bar: "paste", surface: "variants", ctx: "none" }, + { at: 0.545, bar: "paste", surface: "variants", ctx: "none", pick: "0" }, + { at: 0.575, mic: "done", ctx: "none", undo: "1", action: "on", compose: "reply" }, + { at: 0.6, undo: "1", action: "on", compose: "reply", send: true }, + { at: 0.62 }, + { at: 0.715, bar: "skills", ctx: "none" }, + { at: 0.775, bar: "skills", ctx: "extract", mic: "busy", pick: "event" }, + { at: 0.84, ctx: "tip" }, + { at: 0.93 } + ]; + + // 对话气泡:到达阈值即出现;复制提示只在区间内显示 + const STORY_MESSAGES = { m1: 0.02, o1: 0.29, m2: 0.33, o2: 0.62, m3: 0.67, ev: 0.885 }; + const STORY_COPIES = { c2: [0.36, 0.44], c3: [0.705, 0.775] }; + const LIVE_RANGE = [0.075, 0.225]; + const ACT_RANGES = [[0, 0.31], [0.31, 0.655], [0.655, 1]]; const story = document.getElementById("storyStage"); const storyText = document.getElementById("storyText"); + const storyLive = document.getElementById("storyLive"); const storyActs = story ? Array.from(story.querySelectorAll(".story-act")) : []; const storyWaves = story - ? Array.from(story.querySelectorAll(".kb-wave")).map((host, i) => createWave(host, { mirror: i === 0 })) + ? Array.from(story.querySelectorAll(".mic-wave")).map((host) => createWave(host)) : []; let storyLastText = null; - let storyLastTone = -1; + let storyLastLive = null; let storyLastAct = -1; function updateStory(force) { @@ -500,43 +559,39 @@ const total = Math.max(1, story.offsetHeight - viewport); const p = clamp(-rect.top / total); - const lang = root.dataset.lang === "en" ? "en" : "zh"; - const copy = STORY_TEXT[lang]; + const copy = STORY_TEXT[root.dataset.lang === "en" ? "en" : "zh"]; - const reached = STORY_STEPS.map((step) => p >= step); - reached.forEach((on, i) => story.classList.toggle(`s${i + 1}`, on)); - const [, listening, rewriting, sent] = reached; + let phase = STORY_PHASES[0]; + STORY_PHASES.forEach((item) => { if (p >= item.at) phase = item; }); + applyKeyboardState(story, phase); + story.classList.toggle("press-send", Boolean(phase.send)); - // 语气:仅在「改写中」阶段可见,按进度依次切到 普通 → 正式 → 轻松 - let tone = -1; - if (rewriting && !sent) { - TONE_STEPS.forEach((step, i) => { if (p >= step) tone = i; }); + Object.entries(STORY_MESSAGES).forEach(([name, at]) => story.classList.toggle(name, p >= at)); + Object.entries(STORY_COPIES).forEach(([name, [from, to]]) => story.classList.toggle(name, p >= from && p < to)); + + // 实时转写:录音阶段按滚动进度逐字出现 + let live = ""; + if (phase.ctx === "live") { + const characters = Array.from(copy.dictation); + const count = Math.round(clamp((p - LIVE_RANGE[0]) / (LIVE_RANGE[1] - LIVE_RANGE[0])) * characters.length); + live = characters.slice(0, count).join(""); } - if (tone !== storyLastTone) { - [0, 1, 2].forEach((i) => story.classList.toggle(`tone-${i}`, i === tone)); - } - - // 输入框:聆听阶段按滚动进度逐字「听写」,改写阶段显示当前语气的版本 - let text = ""; - if (listening && !rewriting) { - const characters = Array.from(copy.draft); - const count = Math.round(clamp((p - TYPE_START) / (TYPE_END - TYPE_START)) * characters.length); - text = characters.slice(0, count).join(""); - } else if (rewriting && !sent) { - text = tone >= 0 ? copy.tones[tone] : copy.draft; + if (storyLive && live !== storyLastLive) { + storyLive.textContent = live; + storyLastLive = live; } + // 输入框:插入时整句落入(与真实插入行为一致),发送后清空 + const text = phase.compose ? copy[phase.compose] : ""; if (storyText && text !== storyLastText) { - const isRewrite = tone !== storyLastTone && text && storyLastText; storyText.textContent = text; - if (isRewrite && !reduceMotion) { + if (text && !storyLastText && !reduceMotion) { storyText.classList.remove("is-swapping"); void storyText.offsetWidth; storyText.classList.add("is-swapping"); } storyLastText = text; } - storyLastTone = tone; story.classList.toggle("has-text", text.length > 0); // 左侧三幕:高亮当前幕,幕内进度条随滚动填充 @@ -551,7 +606,7 @@ element.style.setProperty("--ap", clamp((p - start) / (end - start)).toFixed(3)); }); - storyWaves.forEach((wave) => { wave.level = listening && !rewriting ? 1 : 0.06; }); + storyWaves.forEach((wave) => { wave.level = phase.mic === "rec" ? 1 : 0; }); if (inView) wakeWaves(); } @@ -559,11 +614,56 @@ story.classList.add("is-live"); languageHooks.push(() => { storyLastText = null; - storyLastTone = -2; + storyLastLive = null; updateStory(true); }); } + /* ---------------- 技能区循环演示 ---------------- */ + + // 复制 → 顶栏换成技能胶囊 → 点「日程」→ 提取 → 加入日历 → 横幅确认,然后重来 + const SKILL_DEMO_STEPS = [ + { ms: 1300 }, + { ms: 1900, bar: "skills", ctx: "none", copied: true }, + { ms: 550, bar: "skills", ctx: "none", pick: "event", copied: true }, + { ms: 1500, bar: "skills", ctx: "extract", mic: "busy", pick: "event" }, + { ms: 1200, ctx: "tip" }, + { ms: 2800, done: true } + ]; + + const skillsDemo = document.getElementById("skillsDemo"); + let skillsDemoVisible = false; + let skillsDemoTimer = 0; + let skillsDemoIndex = 0; + + function showSkillsStep(step) { + applyKeyboardState(skillsDemo, step); + skillsDemo.classList.toggle("is-copied", Boolean(step.copied)); + skillsDemo.classList.toggle("is-done", Boolean(step.done)); + } + + function tickSkillsDemo() { + clearTimeout(skillsDemoTimer); + if (!skillsDemoVisible || document.hidden) return; + const step = SKILL_DEMO_STEPS[skillsDemoIndex]; + showSkillsStep(step); + skillsDemoIndex = (skillsDemoIndex + 1) % SKILL_DEMO_STEPS.length; + skillsDemoTimer = setTimeout(tickSkillsDemo, step.ms); + } + + if (skillsDemo) { + if (reduceMotion) { + // 减弱动态效果:停在「技能胶囊已出现」这一帧 + showSkillsStep(SKILL_DEMO_STEPS[1]); + } else { + new IntersectionObserver((entries) => { + skillsDemoVisible = entries[0].isIntersecting; + tickSkillsDemo(); + }, { threshold: 0.35 }).observe(skillsDemo); + document.addEventListener("visibilitychange", tickSkillsDemo); + } + } + /* ---------------- 滚动引擎 ---------------- */ const progressBar = document.querySelector(".scroll-progress"); diff --git a/docs/index.html b/docs/index.html index 25949fa..07c4b64 100644 --- a/docs/index.html +++ b/docs/index.html @@ -50,7 +50,7 @@ - + + diff --git a/docs/llms.txt b/docs/llms.txt index 1085a50..7dead7d 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -7,8 +7,8 @@ Current repository version: 2.1.0 (build 110). Later work is listed under CHANGE ## Category definitions - AI voice keyboard (AI 语音输入法): OSGKeyboard turns speech into text with on-device recognition by default, lets you hold the mic to ask AI with preview before insert, and lets you revise the last insertion by voice. -- "Zuiti" keyboard (嘴替输入法): "嘴替" is Chinese internet slang for someone who says what you mean, better than you would. OSGKeyboard learns from dictation you have confirmed and rewrites casual speech in your own tone — normal, formal, or playful — with a preview before insert. -- Agentic keyboard (Agentic 键盘): a keyboard that understands content and takes the next step. When you copy text, OSGKeyboard's on-device semantic analysis suggests up to five relevant actions (reply, summarize, translate, to-do, event, notes, navigation); to-dos and events are written natively through EventKit, and suggestions do not upload the clipboard text. +- "Zuiti" keyboard (嘴替输入法): "嘴替" is Chinese internet slang for someone who says what you mean, better than you would. OSGKeyboard learns from dictation you have confirmed; copy a message and it drafts three replies in your tone — ordinary, formal, and relaxed & playful — and you tap one to insert. +- Agentic keyboard (Agentic 键盘): a keyboard that understands content and takes the next step. When you copy text, OSGKeyboard's on-device semantic analysis turns the keyboard's top bar into the relevant actions, ranked by relevance (reply, summarize, translate, tasks, events, notes, navigation); to-dos and events are written natively through EventKit, and suggestions do not upload the clipboard text. ## Product facts