diff --git a/.changeset/web-composer-shell.md b/.changeset/web-composer-shell.md
new file mode 100644
index 00000000..cc0a9107
--- /dev/null
+++ b/.changeset/web-composer-shell.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Widen the chat reading column to 928px and restyle the composer card: a 24px radius, a translucent blurred surface, a border that strengthens on hover and focus, and an input that grows to 384px before it scrolls. The toolbar controls are 30px circles with a divider after the attachment button.
diff --git a/apps/pythinker-web/src/components/ChatDock.vue b/apps/pythinker-web/src/components/ChatDock.vue
index 242931fe..5cdbf9e4 100644
--- a/apps/pythinker-web/src/components/ChatDock.vue
+++ b/apps/pythinker-web/src/components/ChatDock.vue
@@ -332,8 +332,9 @@ defineExpose({ loadForEdit });
/* Use a different surface so the card radius reads against the page, especially in dark mode; --sh only exists on the modern and pythinker themes, hence the fallback. */
.chat-dock :deep(.composer-card) {
- background: var(--panel);
- box-shadow: var(--sh, 0 6px 18px rgba(0, 0, 0, 0.28));
+ background: color-mix(in srgb, var(--panel) 78%, transparent);
+ backdrop-filter: blur(16px);
+ box-shadow: var(--sh, 0 6px 18px color-mix(in srgb, var(--ink) 28%, transparent));
}
.chat-dock.align-center { margin-left: auto; margin-right: auto; }
diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue
index ff1bf16e..2cfcc7a1 100644
--- a/apps/pythinker-web/src/components/Composer.vue
+++ b/apps/pythinker-web/src/components/Composer.vue
@@ -1078,6 +1078,7 @@ function selectModel(modelId: string): void {
>
+
.con {
- --read-max: 760px;
+ --read-max: 928px;
display: flex;
flex-direction: column;
min-width: 0;
diff --git a/apps/pythinker-web/src/style.css b/apps/pythinker-web/src/style.css
index 006f84cf..311ea411 100644
--- a/apps/pythinker-web/src/style.css
+++ b/apps/pythinker-web/src/style.css
@@ -41,13 +41,15 @@
--err: var(--dsw-alias-state-error-secondary);
/* Subtle hover wash for icon buttons (toast close, etc.). */
--hover: var(--dsw-alias-interactive-bg-hover);
- /* Radius scale (Modern). A regular 8 / 12 / 16 progression: list items + pill
- controls use --r-sm, the input box + cards use --r-md, dialogs use --r-lg.
+ /* Radius scale (Modern). A regular 8 / 12 / 16 / 24 progression: list items + pill
+ controls use --r-sm, input boxes use --r-md, dialogs use --r-lg, and the
+ composer card uses --r-xl.
The Terminal theme keeps its own sharper 3–4px corners. */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
+ --r-xl: 24px;
--ui-font-size: 14px;
--ui-font-size-sm: calc(var(--ui-font-size) - 1px);
--ui-font-size-xs: calc(var(--ui-font-size) - 2px);
@@ -387,9 +389,10 @@ html[data-color-scheme="dark"][data-theme="modern"] {
to the global sheet where they apply reliably. The chat surface is white, so
the composer card reads as a rounded card via a soft shadow + hairline border. */
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .composer-card {
- border-radius: var(--r-md);
+ border-radius: var(--r-xl);
border: 1px solid var(--line);
- background: var(--bg);
+ background: color-mix(in srgb, var(--bg) 82%, transparent);
+ backdrop-filter: blur(16px);
box-shadow: var(--shc);
}
@@ -408,12 +411,12 @@ html[data-color-scheme="dark"][data-theme="modern"] {
width: 30px;
min-width: 30px;
height: 30px;
- padding: 0;
+ padding: 5px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
- box-shadow: 0 1px 2px rgba(20, 23, 28, 0.1);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--ink) 10%, transparent);
align-self: flex-end;
}
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .send:hover { background: var(--blue2); }
@@ -427,10 +430,11 @@ html[data-color-scheme="dark"][data-theme="modern"] {
background: color-mix(in srgb, var(--panel), black 1.5%);
}
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .attach-btn {
- width: 26px;
- height: 26px;
+ width: 30px;
+ min-width: 30px;
+ height: 30px;
padding: 0;
- border-radius: var(--r-sm);
+ border-radius: 50%;
}
:is(html[data-theme="modern"], html[data-theme="pythinker"]) .attach-btn:hover {
background: var(--soft);
@@ -855,8 +859,8 @@ html[data-color-scheme="dark"][data-theme="pythinker"] {
/* Composer: the one card that keeps a shadow (effect.shadow.inputDefault),
with the chat-input radius documented in web-best-practices §8. */
html[data-theme="pythinker"] .composer-card {
- border-radius: 20px;
- box-shadow: 0 5px 16px -4px rgba(0, 0, 0, 0.07);
+ border-radius: var(--r-xl);
+ box-shadow: 0 5px 16px -4px color-mix(in srgb, var(--ink) 7%, transparent);
}
/* Cards stay flat: no hover lift, no shadow (Quiet Utility / card.md). */
diff --git a/apps/pythinker-web/test/composer.test.ts b/apps/pythinker-web/test/composer.test.ts
index a618dbcc..3a0bd228 100644
--- a/apps/pythinker-web/test/composer.test.ts
+++ b/apps/pythinker-web/test/composer.test.ts
@@ -1,13 +1,15 @@
import { readFileSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
import { flushPromises, mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { afterEach, describe, expect, it, vi } from 'vitest';
import Composer from '../src/components/Composer.vue';
import type { AppModel } from '../src/api/types';
-const sourcePath = (path: string) => fileURLToPath(new URL(path, import.meta.url));
+const sourcePath = (path: string) => join(import.meta.dirname, path);
const composerSource = readFileSync(sourcePath('../src/components/Composer.vue'), 'utf8');
+const conversationPaneSource = readFileSync(sourcePath('../src/components/ConversationPane.vue'), 'utf8');
+const styleSource = readFileSync(sourcePath('../src/style.css'), 'utf8');
function mountComposer(props: Record = {}) {
const i18n = createI18n({
@@ -82,6 +84,80 @@ afterEach(() => {
});
describe('Composer styling', () => {
+ it('uses the shared xl radius token for the composer card', () => {
+ const composerCardRule = composerSource.match(/(?:^|\n)\.composer-card\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(composerCardRule.trim()).not.toBe('');
+ expect(composerCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/);
+
+ const modernCardRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.composer-card\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(modernCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/);
+
+ const pythinkerCardRule = styleSource.match(/html\[data-theme="pythinker"\] \.composer-card\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(pythinkerCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/);
+ });
+
+ it('defines the xl radius token in the shared style tokens', () => {
+ expect(styleSource).toMatch(/--r-xl:\s*24px;/);
+ });
+
+ it('uses a translucent card surface with a backdrop blur', () => {
+ const composerCardRule = composerSource.match(/(?:^|\n)\.composer-card\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(composerCardRule).toMatch(/background:\s*color-mix\([^;]+transparent\);/);
+ expect(composerCardRule).toMatch(/backdrop-filter:\s*blur\([^;]+\);/);
+ });
+
+ it('strengthens the card border on hover and focus within', () => {
+ const interactionRule = composerSource.match(/(?:^|\n)\.composer-card:hover,\s*\.composer-card:focus-within\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(interactionRule.trim()).not.toBe('');
+ expect(interactionRule).toMatch(/border-color:\s*[^;]+;/);
+ });
+
+ it('caps the input at 384px and scrolls its content', () => {
+ const inputRule = composerSource.match(/(?:^|\n)\.ph\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(inputRule).toMatch(/max-height:\s*384px;/);
+ expect(inputRule).toMatch(/overflow-y:\s*auto;/);
+ expect(inputRule).toMatch(/field-sizing:\s*content;/);
+ });
+
+ it('uses circular attachment controls and a divider after the plus button', () => {
+ const attachRule = composerSource.match(/(?:^|\n)\.attach-btn\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(attachRule).toMatch(/width:\s*30px;/);
+ expect(attachRule).toMatch(/height:\s*30px;/);
+ expect(attachRule).toMatch(/border-radius:\s*50%;/);
+
+ const themedAttachRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.attach-btn\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(themedAttachRule).toMatch(/width:\s*30px;/);
+ expect(themedAttachRule).toMatch(/height:\s*30px;/);
+ expect(themedAttachRule).toMatch(/border-radius:\s*50%;/);
+
+ const dividerRule = composerSource.match(/(?:^|\n)\.toolbar-divider\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(dividerRule).toMatch(/width:\s*1px;/);
+ expect(dividerRule).toMatch(/height:\s*16px;/);
+ expect(composerSource).toMatch(/class="attach-btn"[\s\S]*class="toolbar-divider"/);
+ });
+
+ it('pads the send button around a 20px glyph and keeps the accent fill', () => {
+ const sendRule = composerSource.match(/(?:^|\n)\.send\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(sendRule).toMatch(/padding:\s*5px;/);
+ // The send button carries the theme accent, not a monochrome fill: --blue is
+ // the Pythinker brand colour and each theme redefines it.
+ expect(sendRule).toMatch(/background:\s*var\(--blue\);/);
+ expect(sendRule).toMatch(/color:\s*var\(--bg\);/);
+
+ const sendIconRule = composerSource.match(/(?:^|\n)\.send svg\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(sendIconRule).toMatch(/width:\s*20px;/);
+ expect(sendIconRule).toMatch(/height:\s*20px;/);
+
+ const themedSendRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.send\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(themedSendRule).toMatch(/padding:\s*5px;/);
+ expect(themedSendRule).not.toMatch(/background:\s*var\(--ink\);/);
+ });
+
+ it('widens the shared reading column to 928px', () => {
+ const conRule = conversationPaneSource.match(/(?:^|\n)\.con\s*\{([^}]*)\}/)?.[1] ?? '';
+ expect(conRule).toMatch(/--read-max:\s*928px;/);
+ });
+
it('places the composer border flush against the message list', () => {
const composerRule = composerSource.match(/(?:^|\n)\.composer\s*\{([^}]*)\}/)?.[1] ?? '';
expect(composerRule.trim()).not.toBe('');