Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/web-composer-shell.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions apps/pythinker-web/src/components/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
66 changes: 43 additions & 23 deletions apps/pythinker-web/src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,7 @@ function selectModel(modelId: string): void {
>
<svg class="attach-icon" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><path d="M8 3v10M3 8h10"/></svg>
</button>
<span v-if="hasUpload" class="toolbar-divider" aria-hidden="true" />

<!-- Permission pill — click to open dropdown -->
<span
Expand Down Expand Up @@ -1318,12 +1319,18 @@ function selectModel(modelId: string): void {
.composer-card {
position: relative;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--bg);
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border-radius: var(--r-xl);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(16px);
box-shadow: 0 1px 4px color-mix(in srgb, var(--ink) 4%, transparent);
transition: border-color 0.15s, box-shadow 0.15s;
}

.composer-card:hover,
.composer-card:focus-within {
border-color: color-mix(in srgb, var(--line) 55%, var(--ink));
}



/* Attachment strip */
Expand Down Expand Up @@ -1535,10 +1542,11 @@ function selectModel(modelId: string): void {
font-family: var(--mono);
font-size: var(--ui-font-size);
background: transparent;
height: 56px;
height: auto;
min-height: 56px;
max-height: 56px;
max-height: 384px;
overflow-y: auto;
field-sizing: content;
line-height: 1.5;
margin-bottom: 6px;
}
Expand Down Expand Up @@ -1570,12 +1578,13 @@ function selectModel(modelId: string): void {
/* Send button — circular icon (morphs into the abort square while running) */
.send {
width: 30px;
min-width: 30px;
height: 30px;
border-radius: 50%;
background: var(--blue);
color: var(--bg); /* on-accent text — readable in dark + mono-dark */
border: none;
padding: 0;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
Expand All @@ -1595,6 +1604,8 @@ function selectModel(modelId: string): void {

.send svg {
flex: none;
width: 20px;
height: 20px;
}

.send-icon {
Expand All @@ -1621,9 +1632,9 @@ function selectModel(modelId: string): void {
align-items: center;
justify-content: space-between;
padding: 6px 10px 4px;
background: color-mix(in srgb, var(--panel2), black 1.5%);
background: color-mix(in srgb, var(--panel2) 98.5%, var(--ink) 1.5%);
position: relative;
border-radius: 0 0 var(--r-md) var(--r-md);
border-radius: 0 0 var(--r-xl) var(--r-xl);
}

.toolbar-left,
Expand All @@ -1635,14 +1646,16 @@ function selectModel(modelId: string): void {
overflow: hidden;
}

/* Attach button (pill style, matches permission/plan) */
/* Attach button */
.attach-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px 7px;
border-radius: 6px;
width: 30px;
min-width: 30px;
height: 30px;
padding: 0;
border-radius: 50%;
font-size: var(--ui-font-size);
color: var(--muted);
cursor: pointer;
Expand All @@ -1663,6 +1676,14 @@ function selectModel(modelId: string): void {
background: var(--soft);
}

.toolbar-divider {
width: 1px;
height: 16px;
flex: none;
margin: 0 4px;
background: var(--line);
}

/* Permission pill */
.perm-pill {
display: inline-flex;
Expand Down Expand Up @@ -2125,19 +2146,19 @@ function selectModel(modelId: string): void {
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
}
.composer-card {
border-radius: 14px;
border-radius: var(--r-xl);
max-width: 100%;
}
.input-row {
gap: 6px;
min-width: 0;
}
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
/* Send → 30px round (hide the SVG arrow, show only the ::after glyph) */
.send {
width: 36px;
height: 36px;
min-width: 36px;
padding: 0;
width: 30px;
height: 30px;
min-width: 30px;
padding: 5px;
border-radius: 50%;
font-size: 0;
align-self: flex-end;
Expand All @@ -2149,7 +2170,7 @@ function selectModel(modelId: string): void {
.send::after {
content: "↑";
/* Fixed icon glyph size — not part of the UI font scale. */
font-size: 17px;
font-size: 20px;
line-height: 1;
color: var(--bg);
}
Expand Down Expand Up @@ -2177,14 +2198,13 @@ function selectModel(modelId: string): void {
max-width: calc(100vw - 24px);
}

/* Bump mobile font sizes +2px and pin input at 16px to prevent iOS zoom.
Single-line-friendly height: 56px desktop default → 44px touch target. */
/* Bump mobile font sizes +2px and pin input at 16px to prevent iOS zoom. */
.ph {
/* Pinned at 16px to prevent iOS auto-zoom on focus (not part of UI font scale). */
font-size: 16px;
height: 44px;
height: auto;
min-height: 44px;
max-height: 44px;
max-height: 384px;
}
.model-pill,
.attach-btn {
Expand Down
2 changes: 1 addition & 1 deletion apps/pythinker-web/src/components/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ defineExpose({ loadComposerForEdit });

<style scoped>
.con {
--read-max: 760px;
--read-max: 928px;
display: flex;
flex-direction: column;
min-width: 0;
Expand Down
26 changes: 15 additions & 11 deletions apps/pythinker-web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand All @@ -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); }
Expand All @@ -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);
Expand Down Expand Up @@ -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). */
Expand Down
80 changes: 78 additions & 2 deletions apps/pythinker-web/test/composer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
const i18n = createI18n({
Expand Down Expand Up @@ -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('');
Expand Down
Loading