Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Binary file modified img/en/conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/en/dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/privacy-blur.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/zh/conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/zh/dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 32 additions & 19 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -870,7 +882,6 @@
}

#messageInput {
padding: 10px 14px;
font-size: 16px; /* Prevent zoom on input focus on iOS */
}

Expand Down Expand Up @@ -1107,10 +1118,12 @@
<div id="saveState" class="sr-only" role="status">Opening conversation…</div>
<!-- Input area -->
<div class="chat-input-area" role="form">
<div class="composer">
<textarea id="messageInput" rows="1" placeholder="Say something…" aria-label="Type your message"></textarea>
<button id="sendButton" title="Send message" aria-label="Send message">
<span class="icon">➤</span>
</button>
</div>
</div>
</div>

Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file modified screenshots/screenshot1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified screenshots/screenshot2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion service-worker.js
Original file line number Diff line number Diff line change
@@ -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())));
Expand Down
40 changes: 40 additions & 0 deletions tests/composer.cjs
Original file line number Diff line number Diff line change
@@ -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.right&&r.top>=c.top&&r.bottom<c.bottom,buttonWidth:r.width,paddingRight:parseFloat(getComputedStyle(input).paddingRight),height:i.height,overflow:getComputedStyle(input).overflowY,documentWidth:document.documentElement.scrollWidth,text:input.value};
});
assert.equal(metrics.inside,true,'Send stays inside the shared input surface');
assert.equal(metrics.right,8);assert.equal(metrics.bottom,8);
assert.equal(metrics.buttonWidth,44);assert.ok(metrics.paddingRight>=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});
51 changes: 32 additions & 19 deletions web/index.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -870,7 +882,6 @@
}

#messageInput {
padding: 10px 14px;
font-size: 16px; /* Prevent zoom on input focus on iOS */
}

Expand Down Expand Up @@ -1107,10 +1118,12 @@
<div id="saveState" class="sr-only" role="status">Opening conversation…</div>
<!-- Input area -->
<div class="chat-input-area" role="form">
<div class="composer">
<textarea id="messageInput" rows="1" placeholder="Say something…" aria-label="Type your message"></textarea>
<button id="sendButton" title="Send message" aria-label="Send message">
<span class="icon">➤</span>
</button>
</div>
</div>
</div>

Expand Down Expand Up @@ -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;
}
Expand Down
Loading