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;
}