diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2ff4b34..bc016b9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -20,6 +20,7 @@ jobs: - run: npm test - run: npx playwright install --with-deps chromium - run: npm run test:motion + - run: npm run test:composer - run: npm run test:browser - uses: actions/upload-artifact@v4 if: always() diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c782d0d..876c617 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,3 +30,5 @@ The conversation keeps native wheel, touch and keyboard scrolling with a custom Aging is applied to a shared content wrapper so the body and timestamp fade and blur together, while deletion controls stay clear. Language follows the browser; Chinese and English documentation screenshots are captured from isolated example histories with `npm run screenshots`. These choices follow [Apple's motion guidance](https://developer.apple.com/design/human-interface-guidelines/motion): purposeful, brief feedback, input available during animation, and accessibility preferences. Smoothness and power are tested separately from correctness; there is no measured battery-life claim. + +The composer keeps its send button inside a single rounded input surface. A 44 px target stays in the lower-right corner as the draft grows, with reserved text padding and no visible input scrollbar. Run `npm run test:composer` for narrow layouts, both themes, caret editing and complete sends. diff --git a/img/en/conversation.png b/img/en/conversation.png index 3c62ee4..04ff11e 100644 Binary files a/img/en/conversation.png and b/img/en/conversation.png differ diff --git a/img/en/dark.png b/img/en/dark.png index f08e155..6817e73 100644 Binary files a/img/en/dark.png and b/img/en/dark.png differ diff --git a/img/privacy-blur.png b/img/privacy-blur.png index f11eea2..a39b92f 100644 Binary files a/img/privacy-blur.png and b/img/privacy-blur.png differ diff --git a/img/screenshot.png b/img/screenshot.png index 7c7c7c9..a91e034 100644 Binary files a/img/screenshot.png and b/img/screenshot.png differ diff --git a/img/zh/conversation.png b/img/zh/conversation.png index 83d4d93..8757b93 100644 Binary files a/img/zh/conversation.png and b/img/zh/conversation.png differ diff --git a/img/zh/dark.png b/img/zh/dark.png index 4b2bcc1..5a64670 100644 Binary files a/img/zh/dark.png and b/img/zh/dark.png differ diff --git a/index.html b/index.html index af71cc2..c35af47 100644 --- a/index.html +++ b/index.html @@ -422,38 +422,50 @@ flex-shrink: 0; } - #messageInput { - flex: 1; - padding: 12px 16px; + .composer { + position: relative; + width: 100%; + min-width: 0; border: 1px solid var(--border-color); border-radius: 24px; - font-size: var(--font-size-normal); background-color: var(--input-background); - color: var(--text-color); - transition: background-color .2s, color .2s, transform .2s; - outline: none; box-shadow: 0 1px 3px var(--shadow-color); - -webkit-appearance: none; /* Remove iOS default styles */ - min-width: 0; /* Prevent flex item overflow */ + transition: background-color .2s, border-color .2s; } - #messageInput:focus { + .composer:focus-within { border-color: var(--button-color); box-shadow: 0 0 0 2px rgba(124, 156, 129, 0.2); } + #messageInput { + display: block; + width: 100%; + min-height: 58px; + padding: 16px 64px 16px 18px; + border: 0; + border-radius: inherit; + font-size: var(--font-size-normal); + background: transparent; + color: var(--text-color); + outline: none; + -webkit-appearance: none; + } + /* Send button styling */ #sendButton { - margin-left: 10px; + position: absolute; + right: 7px; + bottom: 7px; background-color: var(--button-color); color: var(--button-text); border: none; - width: 48px; - height: 48px; - min-width: 48px; - min-height: 48px; - max-width: 48px; - max-height: 48px; + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + max-width: 44px; + max-height: 44px; border-radius: 50%; cursor: pointer; transition: background-color .2s, color .2s, transform .2s; @@ -870,7 +882,6 @@ } #messageInput { - padding: 10px 14px; font-size: 16px; /* Prevent zoom on input focus on iOS */ } @@ -1107,10 +1118,12 @@
Opening conversation…
+
+
@@ -2438,7 +2451,7 @@ function resizeComposer() { const top = messagesContainer.scrollTop, pinned = transcript.pinned; messageInput.style.height = 'auto'; - messageInput.style.height = (messageInput.scrollHeight + 2) + 'px'; + messageInput.style.height = messageInput.scrollHeight + 'px'; if(pinned)messagesContainer.scrollTop=messagesContainer.scrollHeight; else messagesContainer.scrollTop=top; } diff --git a/package.json b/package.json index dac09f3..128880d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build:icons": "node scripts/build-icons.cjs", "benchmark": "node tests/performance.cjs", "test:motion": "node tests/motion.cjs", - "screenshots": "node scripts/capture-screenshots.cjs" + "screenshots": "node scripts/capture-screenshots.cjs", + "test:composer": "node tests/composer.cjs" }, "devDependencies": { "playwright": "^1.58.2", diff --git a/screenshots/screenshot1.png b/screenshots/screenshot1.png index 7c1c86a..9ebb38b 100644 Binary files a/screenshots/screenshot1.png and b/screenshots/screenshot1.png differ diff --git a/screenshots/screenshot2.png b/screenshots/screenshot2.png index 22bc944..add7965 100644 Binary files a/screenshots/screenshot2.png and b/screenshots/screenshot2.png differ diff --git a/service-worker.js b/service-worker.js index 8e7b3d5..ce52bb0 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ // Cache only this application's public assets; conversations remain in IndexedDB. const PREFIX = 'cwg:' + self.registration.scope + ':'; -const CACHE_NAME = PREFIX + '2.1.1'; +const CACHE_NAME = PREFIX + '2.1.2'; const ASSETS = ['./','./index.html','./manifest.json','./icons/favicon-32x32.png','./icons/apple-touch-icon.png','./icons/icon-192x192.png','./icons/icon-512x512.png','./icons/maskable-icon.png']; self.addEventListener('install',event=>event.waitUntil(caches.open(CACHE_NAME).then(cache=>cache.addAll(ASSETS)).then(()=>self.skipWaiting()))); self.addEventListener('activate',event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith(PREFIX)&&key!==CACHE_NAME).map(key=>caches.delete(key)))).then(()=>self.clients.claim()))); diff --git a/tests/composer.cjs b/tests/composer.cjs new file mode 100644 index 0000000..89a2923 --- /dev/null +++ b/tests/composer.cjs @@ -0,0 +1,40 @@ +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'); +const {chromium}=require('playwright'); +const root=path.resolve(__dirname,'..'),output=path.join(root,'.test-results/composer'); +(async()=>{ + fs.mkdirSync(output,{recursive:true}); + const installed='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const browser=await chromium.launch({headless:true,...(fs.existsSync(installed)?{executablePath:installed}:{})}); + try { + for(const theme of ['light','dark'])for(const width of [278,320,640]) { + const context=await browser.newContext({viewport:{width,height:568},locale:'zh-CN',serviceWorkers:'block'}),page=await context.newPage(); + await page.addInitScript(theme=>localStorage.setItem('theme',theme),theme); + await page.goto('file://'+root+'/index.html');await page.waitForFunction(()=>!document.getElementById('messageInput').disabled); + const input=page.locator('#messageInput'); + for(const text of ['一句话','先放慢一点。\n把今天发生的事写下来,\n不用急着寻找答案。\n慢慢梳理自己的想法。','较长的输入内容,仍然可以自在地换行。\n'.repeat(60)]) { + await input.fill(text);await input.press('ControlOrMeta+End');await input.press('Shift+Enter');await input.press('x'); + await page.mouse.move(0,0); + const metrics=await page.evaluate(()=>{ + const input=document.getElementById('messageInput'),button=document.getElementById('sendButton'),composer=document.querySelector('.composer'); + const r=button.getBoundingClientRect(),c=composer.getBoundingClientRect(),i=input.getBoundingClientRect(); + return {right:c.right-r.right,bottom:c.bottom-r.bottom,inside:r.left>c.left&&r.right=c.top&&r.bottom=60,'Text never runs under the send button'); + assert.equal(metrics.overflow,'hidden');assert.ok(metrics.height<=160);assert.ok(metrics.documentWidth<=width); + assert.equal(metrics.text,text+'\nx'); + } + // The complete long draft is sent once and the input returns to one line. + const complete=await input.inputValue();await page.locator('#sendButton').click(); + assert.equal(await page.locator('.message:last-child .message-text').textContent(),complete.trim()); + assert.equal(await input.inputValue(),'');assert.ok((await input.boundingBox()).height<=62); + if(width===320&&theme==='light') { + await input.fill('可以先放慢一点,\n把今天的感受写下来。\n不急着找到答案,\n也不用一次说完。'); + await page.waitForTimeout(300);await page.screenshot({path:path.join(output,'multiline-zh.png')}); + } + await context.close(); + } + console.log('Integrated send button, multiline caret editing, complete sends and narrow layouts passed in both themes.'); + } finally { await browser.close(); } +})().catch(error=>{console.error(error);process.exitCode=1}); diff --git a/web/index.template.html b/web/index.template.html index 707ead0..8c36c9a 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -422,38 +422,50 @@ flex-shrink: 0; } - #messageInput { - flex: 1; - padding: 12px 16px; + .composer { + position: relative; + width: 100%; + min-width: 0; border: 1px solid var(--border-color); border-radius: 24px; - font-size: var(--font-size-normal); background-color: var(--input-background); - color: var(--text-color); - transition: background-color .2s, color .2s, transform .2s; - outline: none; box-shadow: 0 1px 3px var(--shadow-color); - -webkit-appearance: none; /* Remove iOS default styles */ - min-width: 0; /* Prevent flex item overflow */ + transition: background-color .2s, border-color .2s; } - #messageInput:focus { + .composer:focus-within { border-color: var(--button-color); box-shadow: 0 0 0 2px rgba(124, 156, 129, 0.2); } + #messageInput { + display: block; + width: 100%; + min-height: 58px; + padding: 16px 64px 16px 18px; + border: 0; + border-radius: inherit; + font-size: var(--font-size-normal); + background: transparent; + color: var(--text-color); + outline: none; + -webkit-appearance: none; + } + /* Send button styling */ #sendButton { - margin-left: 10px; + position: absolute; + right: 7px; + bottom: 7px; background-color: var(--button-color); color: var(--button-text); border: none; - width: 48px; - height: 48px; - min-width: 48px; - min-height: 48px; - max-width: 48px; - max-height: 48px; + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + max-width: 44px; + max-height: 44px; border-radius: 50%; cursor: pointer; transition: background-color .2s, color .2s, transform .2s; @@ -870,7 +882,6 @@ } #messageInput { - padding: 10px 14px; font-size: 16px; /* Prevent zoom on input focus on iOS */ } @@ -1107,10 +1118,12 @@
Opening conversation…
+
+
@@ -2160,7 +2173,7 @@ function resizeComposer() { const top = messagesContainer.scrollTop, pinned = transcript.pinned; messageInput.style.height = 'auto'; - messageInput.style.height = (messageInput.scrollHeight + 2) + 'px'; + messageInput.style.height = messageInput.scrollHeight + 'px'; if(pinned)messagesContainer.scrollTop=messagesContainer.scrollHeight; else messagesContainer.scrollTop=top; }