diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5bafd1e92..b12793f2a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -600,7 +600,9 @@ "taskClaimedVerb": "claimed" }, "claimRun": { - "label": "{{count}} claims · {{first}} to {{last}}" + "label": "{{count}} claims · {{first}} to {{last}}", + "count": "{{count}} claims", + "range": "{{first}} to {{last}}" }, "starts": { "label": "Ways to start", @@ -764,6 +766,7 @@ "startThread": "Thread", "replyInThread": "Reply in thread", "replyFromExpandedThread": "Reply from expanded thread", + "groupLabel": "Thread, {{count}} {{unit}}", "replyingInThread": "Replying in thread ·", "last": "last", "collapse": "Collapse", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index defedaa7a..58be3e901 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -600,7 +600,9 @@ "taskClaimedVerb": "认领了" }, "claimRun": { - "label": "{{count}}次认领 · {{first}} 至 {{last}}" + "label": "{{count}}次认领 · {{first}} 至 {{last}}", + "count": "{{count}}次认领", + "range": "{{first}} 至 {{last}}" }, "starts": { "label": "开始方式", @@ -763,6 +765,7 @@ "startThread": "话题", "replyInThread": "在话题中回复", "replyFromExpandedThread": "在已展开的话题中回复", + "groupLabel": "话题,{{count}} {{unit}}", "replyingInThread": "正在话题中回复:", "last": "最近", "collapse": "收起", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 14c88bf66..1a55a6484 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -17,6 +17,16 @@ import '@fontsource/ibm-plex-mono/500.css'; import './v2.css'; // D223 shared primitives (TASK-730): the keys and segment chat and the sidebar use. import './primitives/primitives.css'; +// Chat surface CSS lifted out of v2.css (D223, S3). Imported here, after +// v2.css, so its rules still win the order ties they won inside v2.css (a +// component-level import would load before v2.css: V2Layout is imported above). +// No chat rule ties a primitive on specificity, so following primitives.css +// changes nothing. +import './chat/chat-message.css'; +import './chat/chat-pane.css'; +import './chat/chat-composer.css'; +import './chat/chat-inspector.css'; +import './chat/chat-states.css'; import { applyV2Theme, getV2Theme } from './utils/theme'; // TASK-145 — inside the shell, only the surfaces the composer path needs stay diff --git a/frontend/src/v2/__testkit__/v2Css.ts b/frontend/src/v2/__testkit__/v2Css.ts new file mode 100644 index 000000000..2726c73af --- /dev/null +++ b/frontend/src/v2/__testkit__/v2Css.ts @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; + +// The v2 stylesheet as the app loads it: v2.css, then each surface file lifted +// out of it (D223), in V2App.tsx's import order. Tests that read "the v2 CSS" +// read this, so a rule keeps its pin when its lift moves it to its own file. +export const V2_CSS_FILES = [ + 'v2.css', + 'chat/chat-message.css', + 'chat/chat-pane.css', + 'chat/chat-composer.css', + 'chat/chat-inspector.css', + 'chat/chat-states.css', +]; + +export const readV2Css = (): string => V2_CSS_FILES + .map((file) => fs.readFileSync(path.join(__dirname, '..', file), 'utf8')) + .join('\n'); diff --git a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx index 7d08c2b4d..a21770a58 100644 --- a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx +++ b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx @@ -91,6 +91,20 @@ describe('claim run summary control (TASK-692)', () => { expect(claimRowTaskIds(container)).toEqual([]); }); + test('the bar draws the D215 summary: board glyph, faces, bold count, range, time, chevron', () => { + const { container } = renderClaims(); + const toggle = screen.getByRole('button', { name: '4 claims · TASK-674 to TASK-678' }); + // One claimer here, so one face; the faces, label and time are aria-hidden + // because the button's accessible name already says the run in one line. + expect(toggle.querySelectorAll('.v2-claim-run__faces .v2-avatar')).toHaveLength(1); + expect(toggle.querySelector('.v2-claim-run__label b')).toHaveTextContent('4 claims'); + expect(toggle.querySelector('.v2-claim-run__label')).toHaveTextContent('4 claims · TASK-674 to TASK-678'); + expect(toggle.querySelector('.v2-claim-run__time')).not.toBeNull(); + expect(toggle.querySelector('.v2-claim-run__chev svg')).not.toBeNull(); + act(() => toggle.click()); + expect(container.querySelectorAll('.v2-claim-run__list .v2-system-action-line')).toHaveLength(4); + }); + test('the expanded rows sit inside the summary group, below the toggle', () => { const { container } = renderClaims(); act(() => screen.getByRole('button', { name: /4 claims/ }).click()); diff --git a/frontend/src/v2/__tests__/V2Composer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx index 3c033609a..aa23851c9 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -144,11 +144,12 @@ describe('V2Composer send button', () => { }); }); - test('there is no Send until the draft has text', () => { + // S3c (D215): Send is drawn at rest, and disabled until the draft has text. + test('Send is disabled, keeping its name, until the draft has text', () => { renderChat(makeDetail()); - expect(screen.queryByRole('button', { name: /send message/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send message/i })).toBeDisabled(); fireEvent.change(composerInput(), { target: { value: 'hello' } }); - expect(screen.getByRole('button', { name: /send message/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send message/i })).toBeEnabled(); }); test('shows send failures by the composer and keeps the reply draft intact', async () => { diff --git a/frontend/src/v2/__tests__/V2ComposerMenu.test.tsx b/frontend/src/v2/__tests__/V2ComposerMenu.test.tsx index f3585d3ba..8cc7222c9 100644 --- a/frontend/src/v2/__tests__/V2ComposerMenu.test.tsx +++ b/frontend/src/v2/__tests__/V2ComposerMenu.test.tsx @@ -38,12 +38,13 @@ const renderComposer = (overrides = {}) => { describe('V2Composer (direction C)', () => { beforeAll(async () => { await i18nReady; await act(async () => { await i18n.changeLanguage('en'); }); }); - test('one row: a 24px plus, a one-line field, no identity line, and no Send until there is text', () => { + test('one row: a 24px plus, a one-line field, no identity line, and a disabled Send until there is text', () => { const { container } = renderComposer(); expect(container.querySelector('.v2-composer__row')).toBeTruthy(); expect(screen.getByRole('textbox')).toHaveAttribute('rows', '1'); expect(screen.queryByText(/posts as/)).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument(); + // S3c (D215): Send is drawn at rest and disabled with an empty draft. + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-haspopup', 'menu'); }); diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx index d17137231..8dea06a80 100644 --- a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx +++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import fs from 'fs'; import path from 'path'; import V2PageHead from '../components/V2PageHead'; +import { readV2Css } from '../__testkit__/v2Css'; const source = (name: string) => fs.readFileSync(path.resolve(__dirname, '..', name), 'utf8'); @@ -51,8 +52,10 @@ describe('page head copy budget', () => { }); test('phone disclosure keeps its trigger in the title row and uses the shared focus ring', () => { - const css = source('v2.css'); - expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .v2-thread__title-line .v2-hint--open { display: contents; }'); + const css = readV2Css(); + // The chat half of this rule moved to chat/chat-pane.css (D223, S3b). + expect(css).toContain('.v2-page-head__identity .v2-hint--open { display: contents; }'); + expect(css).toContain('.v2-thread__title-line .v2-hint--open { display: contents; }'); expect(css).toContain('.v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; }'); expect(css).toContain('.v2-thread__title-line .v2-hint__content { width: 100%; flex-basis: 100%; order: 1; }'); expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 10px 0;[^}]*align-items: flex-start;/); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 0acfe60d8..c8ce18ebb 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { blockAt, blockContaining } from '../lib/cssBlocks'; +import { readV2Css } from '../__testkit__/v2Css'; /** * Layout-invariant guards for v2 CSS rules that regressed in production. @@ -166,7 +167,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(cli).toContain('.v2-root.v2-cli-authorize .v2-cli-authorize__card form {'); }); - const v2 = read('../v2.css'); + const v2 = readV2Css(); const tokens = read('../../../design-system/tokens.css'); const aprofile = read('../agents/v2-agent-profile.css'); const agentSettings = read('../agents/v2-agent-settings.css'); @@ -611,8 +612,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-thread .v2-msg')).toContain('grid-template-columns: 36px'); expect(ruleBody(v2, '.v2-thread .v2-msg__avatar-ghost')).toContain('width: 36px'); expect(ruleBody(v2, '.v2-msg__gutter-time')).toContain('width: 36px'); - // The typing line is not a message row; its faces stay small. - expect(ruleBody(v2, '.v2-thread .v2-chat__typing-avatars .v2-avatar')).toContain('width: 20px'); + // S3b: the typing line is the D215 presence chip; it carries a 7px dot, + // not faces, so nothing in it can take the 36px message column. + expect(ruleBody(v2, '.v2-chat__typing-dot')).toContain('width: 7px'); + expect(thread).not.toContain('v2-chat__typing-avatars'); // The phone hides the panel's Hide key; .v2-root button. outranks // the primitive key's display. expect(mediaBlockContaining(v2, '.v2-workspace-inspector__hide')).toMatch(/\.v2-root button\.v2-workspace-inspector__hide \{ display: none; \}/); @@ -830,9 +833,15 @@ describe('v2 layout invariants (CSS rule presence)', () => { const block = v2.slice(start, v2.indexOf('@media', start + 10)); expect(block).toContain('.v2-shell:not(.v2-shell--feature-wide)'); expect(block).not.toMatch(/\.v2-shell\s*\{/); - expect(block).toContain('.v2-pane--inspector'); - expect(block).toContain('position: fixed'); expect(block).not.toContain('display: none'); + // S3c (D223): the drawer rule moved to chat/chat-inspector.css in its own + // 1023px block; read that block the same way. + const drawerStart = v2.indexOf('@media (max-width: 1023px) {\n /* Below the 3-pane width the inspector'); + expect(drawerStart).toBeGreaterThan(-1); + const drawer = v2.slice(drawerStart, v2.indexOf('@media', drawerStart + 10)); + expect(drawer).toContain('.v2-pane--inspector'); + expect(drawer).toContain('position: fixed'); + expect(drawer).not.toContain('display: none'); // The 760px secondary-pane eraser out-specifies the drawer (0-4-0 vs // 0-1-0); the inspector must be in its :not chain or the drawer is a // mounted-but-invisible pane again — the exact live bug, twice. @@ -1039,7 +1048,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { // made the transcript horizontally swipeable (2026-08-24; 350 vs // 326 scrollWidth at 390). The inner wrap must never take a width. expect(ruleBody(v2, '.v2-thread .v2-thread-block__inner')).not.toContain('width'); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-thread-block__inner \{[\s\S]*?margin-left: 24px/); + // S3b: the phone indent is D215's 40px (.app.m .thr), in the 760 block. + expect(ruleBody(v2, '.v2-thread .v2-thread-block__inner', { within: mediaBlockContaining(v2, '.v2-thread .v2-thread-block__inner') })).toContain('margin-left: 40px'); // Belt-and-braces: the transcript itself never scrolls sideways. expect(ruleBody(v2, '.v2-chat__messages')).toContain('overflow-x: hidden'); expect(ruleBody(v2, '.v2-thread-block')).toContain('margin-bottom'); @@ -1068,7 +1078,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(cssVariable(v2Root, name)).toBe(cssVariable(tokens, name)); } - expect(ruleBody(v2, '.v2-chat__typing-dots > span')).toContain('var(--motion-breath)'); + // S3b: the typing dots became the presence chip's pulse; the breath token + // is still consumed by the boot mark (asserted above). + expect(ruleBody(v2, '.v2-chat__typing-dot::after')).toContain('var(--motion-pulse)'); expect(ruleBody(v2, '.v2-workspace-inspector__state--working')).toContain('var(--motion-pulse)'); expect(ruleBody(v2, '.v2-spinner')).toContain('var(--motion-spin)'); }); @@ -1442,7 +1454,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podBoard).not.toContain('+ {t(\'board.newTask\')}'); }); - test('the composer is one line with a 32px plus menu and a 32px ink Send that exists only with text (D215, TASK-734)', () => { + test('the composer is one line with a 32px plus menu and a 32px ink Send, disabled until there is text (D215, TASK-734)', () => { expect(lastRuleBody(v2, '.v2-composer__row')).toContain('min-height: 52px'); expect(ruleBody(v2, '.v2-composer')).toContain('border-radius: var(--v2-r-14, 14px)'); // D223: plus and Send are the primitives' 32px ghost and primary icon keys, @@ -1460,7 +1472,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(primitives, '.v2p-b-p')).toContain('background: linear-gradient(180deg, var(--v2-fill-a), var(--v2-fill-b))'); // A bespoke look on Send would outrank the primitive (0,2,1 over 0,1,0). expect(lastRuleBody(v2, '.v2-root button.v2-composer__send')).not.toContain('background'); - expect(composer).toContain('{hasText && ('); + // S3c: Send is drawn at rest (D215) and disabled while the draft is empty; + // only an in-flight send takes the dimmed progress look. + expect(composer).toContain('disabled={sending || !hasText}'); + expect(composer).not.toContain('{hasText && ('); + expect(ruleBody(v2, '.v2-root .v2-composer--typing button.v2-composer__send:disabled')).toContain('opacity: 0.6'); + expect(ruleBody(v2, '.v2-root button.v2-composer__send:disabled')).not.toContain('opacity'); + // Ruling 7: the placeholder reads at 4.97:1 (dark 5.77:1), not --ph's 2.58:1. + expect(ruleBody(v2, '.v2-root .v2-composer__field > textarea::placeholder')).toContain('var(--v2-text-muted)'); expect(composer).toContain("role=\"menu\""); expect(composer).not.toContain('postsAs'); expect(v2).not.toContain('.v2-composer__posts-as'); @@ -1487,16 +1506,25 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-thread .v2-msg__quote')).toContain('border-left: 2px solid var(--v2-border)'); expect(ruleBody(v2, '.v2-thread .v2-msg__quote-text')).toContain('-webkit-line-clamp: 2'); expect(ruleBody(v2, '.v2-root .v2-thread button.v2-msg__thread-chip')).toContain('min-height: 28px'); - expect(ruleBody(v2, '.v2-thread .v2-thread-block--open')).toContain('background: var(--v2-bg-subtle)'); - expect(ruleBody(v2, '.v2-thread .v2-thread-replies')).toContain('border-left: 2px solid var(--v2-border)'); - expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('border-radius: 4px'); - expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('box-shadow: none'); - expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('padding: 1px'); + // S3b (#3178 / TASK-745): no band behind the root; the replies are the + // D215 .thr card, with no rail. + expect(v2).not.toContain('.v2-thread-block--open {'); + const thr = ruleBody(v2, '.v2-thread .v2-thread-replies'); + expect(thr).toContain('background: var(--v2-bg-subtle)'); + expect(thr).toContain('box-shadow: inset 0 0 0 1px var(--v2-border-soft)'); + expect(thr).toContain('border-radius: 10px'); + expect(thr).not.toContain('border-left'); + // S3a: the strip is the D215 .acts pop card (r9, 2px in, pop shadow). + expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('border-radius: 9px'); + expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('box-shadow: var(--v2-shadow-pop)'); + expect(ruleBody(v2, '.v2-thread .v2-msg__strip')).toContain('padding: 2px'); + expect(ruleBody(v2, '.v2-root .v2-thread button.v2-msg__action')).toContain('width: 28px; height: 28px'); expect(ruleBody(v2, '.v2-thread .v2-avatar--flat')).toContain('font: 650 12px/1 var(--v2-font)'); expect(ruleBody(v2, '.v2-thread .v2-thread-replies .v2-msg .v2-avatar')).toContain('font-size: 12px'); expect(ruleBody(v2, '.v2-thread .v2-thread-card__faces .v2-avatar')).toContain('font: 650 11px/1 var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-thread .v2-thread-card__faces .v2-avatar--flat')).toContain('font: 650 11px/1 var(--v2-font-mono)'); - expect(v2).toMatch(/@media \(max-width: 760px\)[\s\S]*?\.v2-root \.v2-thread button\.v2-msg__action \{ width: 24px; height: 24px; \}/); + expect(mediaBlockContaining(v2, '.v2-root .v2-thread button.v2-msg__action::after')) + .toContain('.v2-root .v2-thread button.v2-msg__action { position: relative; width: 28px; height: 28px; }'); expect(lastRuleBody(v2, '.v2-root button.v2-thread__jump')).toContain('border: 1px solid #dde0e6'); expect(lastRuleBody(v2, '.v2-root button.v2-thread__jump')).toContain('border-radius: 14px'); expect(messageRow).toContain('v2-msg__tag'); @@ -1514,8 +1542,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { // `(pointer: coarse)` or width term styles machines where the reveal never // fires (sprint-review 64468 + 64485). It must also come after the strip // rules it overrides, since they win on order at equal specificity. - const capability = v2.indexOf('@media (hover: none) {'); - expect(capability).toBeGreaterThan(v2.indexOf('.v2-thread .v2-msg__strip {')); + const strip = v2.indexOf('.v2-thread .v2-msg__strip {'); + const capability = v2.indexOf('@media (hover: none) {', strip); + expect(capability).toBeGreaterThan(strip); expect(v2.slice(capability, capability + 600)).toContain('.v2-thread .v2-msg--reveal .v2-msg__strip'); // ux-lead 64476: root inside the band at the text column; chip-only rest state; // mention wash; flat two-tone transcript avatars. @@ -1698,7 +1727,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { const avatarRule = ruleBody(v2, '.v2-thread .v2-avatar'); const author = selectorRuleBody(v2, '.v2-thread .v2-msg__author'); const time = ruleBody(v2, '.v2-thread .v2-msg__time'); - const actions = ruleBody(v2, '.v2-thread .v2-msg__actions'); + const actions = ruleBody(v2, '.v2-thread .v2-msg__strip'); // D215 (TASK-734): display title, 36px faces at radius 9 on a 1px ring, // and the time in the 11.5 mono the runtime label shares. expect(title).toContain('var(--v2-font-display)'); @@ -1709,8 +1738,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(author).toContain('font-size: 14px'); expect(author).toContain('font-weight: 600'); expect(time).toContain('font: 500 11.5px/16px var(--v2-font-mono)'); - expect(actions).toContain('border-radius: 4px'); - expect(actions).toContain('box-shadow: none'); + // S3a: the hover actions are the D215 .acts pop card. + expect(actions).toContain('border-radius: 9px'); + expect(actions).toContain('box-shadow: var(--v2-shadow-pop)'); // These scoped rules only take effect when the chat mounts the thread // surface class; without it the legacy 30px circular avatar rules win. expect(thread).toContain('className="v2-chat v2-thread"'); @@ -2003,7 +2033,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { // Join policy is now chosen at creation, but public visibility remains a // later act. The old option buttons lost to the global button reset (#870). expect(v2).not.toMatch(/\.v2-pods__create-option[^\n]*\{/); - expect(ruleBody(v2, '.v2-pods__join-policy,\n.v2-pod-join-settings')).toContain('display: flex'); + // S3c (D223): the pod-settings half of this shared rule moved to + // chat/chat-inspector.css; each half keeps the flex layout. + expect(ruleBody(v2, '.v2-pods__join-policy')).toContain('display: flex'); + expect(ruleBody(v2, '.v2-pod-join-settings')).toContain('display: flex'); }); test('the approval card visually outranks the action over the agent prose', () => { @@ -2128,6 +2161,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { // Direction C removed the sidebar channel rows (walk-1 miss 8): the // live-channel dot is no longer a cobalt background in the shell. expect(cobalt).toEqual([ + // S3b: D215 .presence carries a 7px cobalt dot (.dot--pulse). + '.v2-chat__typing-dot', '.v2-rail__brand-icon', // D223 (TASK-735): the rail's needs-you count is the artboard's filled // cobalt badge. @@ -2252,22 +2287,23 @@ describe('v2 layout invariants (CSS rule presence)', () => { // — and card, rail, and message text share one line. Rev 2's // avatar-centre axis (margin 14 → x = 15) is superseded: the block no // longer starts at the avatar edge, so there is no avatar to aim at. - const rail = ruleBody(v2, '.v2-thread-replies'); - expect(rail).toContain('margin-left: 0'); - expect(rail).toContain('padding-left: 12px'); - expect(rail).toContain('border-left: 2px solid #eef0f6'); - - // ONE inner column for every reply: 16 + 12 + 24 + 8 = x = 60. Reusing the - // channel's 38px+12px grid is what opened a second text column, so the - // override is the fix and pinning it is the point. - const railRow = ruleBody(v2, '.v2-thread-replies .v2-msg'); + // S3b (#3178 / TASK-745, D215 .thr): the rail is gone. The replies are a + // card at the block's text-column edge, and each reply is the frame's + // 24px / 10px row. jsdom cannot see any of this, so pin the numbers. + const card = ruleBody(v2, '.v2-thread .v2-thread-replies'); + expect(card).toContain('padding: 10px 12px 8px'); + expect(card).toContain('gap: 8px'); + expect(ruleBody(v2, '.v2-thread-replies')).not.toContain('border-left'); + const railRow = ruleBody(v2, '.v2-thread .v2-thread-replies .v2-msg'); expect(railRow).toContain('grid-template-columns: 24px minmax(0, 1fr)'); - expect(railRow).toContain('gap: 8px'); + expect(railRow).toContain('gap: 10px'); + expect(ruleBody(v2, '.v2-thread .v2-thread-replies .v2-msg__content')).toContain('font-size: 13.5px'); + // Ruling 9: the old unscoped reply-row overrides are deleted, not out-ranked. + expect(v2).not.toMatch(/\n\.v2-thread-replies \.v2-msg(?:__head|__author|__time|--grouped)? \{/); - // 390 (rev 4): the inner wrap's text-column indent tightens to 24px, - // the rail stays on its edge, and the inner padding tightens. - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-thread-block__inner\s*\{[\s\S]*?margin-left: 24px/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-thread-replies\s*\{[\s\S]*?margin-left: 0[\s\S]*?padding-left: 8px/); + // 390: D215 .app.m .thr — 40px indent, tighter card padding. + const phone = mediaBlockContaining(v2, '.v2-thread .v2-thread-replies {'); + expect(ruleBody(v2, '.v2-thread .v2-thread-replies', { within: phone })).toContain('padding: 8px 10px 8px'); }); test('the thread card has no shadow and no accent at rest', () => { @@ -2298,7 +2334,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { // `.v2-msg__actions { opacity: 1 }` here would put chrome over every // message by default (reported 2026-08-23). expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-msg--reveal \.v2-msg__actions[\s\S]*?opacity: 1/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root button\.v2-msg__action[\s\S]*?height: 44px/); + // S3a: D215 draws 28px keys, so touch keeps a 44px HIT AREA (28 + 2 x 8) + // rather than a 44px key, on coarse pointers and on phone widths. + const coarse = blockContaining(v2, '@media (hover: none), (pointer: coarse)', '.v2-root .v2-thread button.v2-msg__action::after'); + expect(coarse).toContain(".v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; }"); + expect(mediaBlockContaining(v2, '.v2-root .v2-thread button.v2-msg__action::after')) + .toContain(".v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; }"); // Reply in thread / Follow / Collapse moved from the chip to the band's // foot (ux-lead 64476 (3)) — the 44px floor moved with them. expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-thread-replies__foot button[\s\S]*?min-height: 44px/); @@ -2326,8 +2367,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(negative.length).toBeGreaterThan(0); const resetStart = v2.indexOf('.v2-root:lang(zh) .v2-rail__brand'); expect(resetStart).toBeGreaterThan(-1); - const resetBlock = v2.slice(resetStart, v2.indexOf('}', resetStart)); - expect(resetBlock).toContain('letter-spacing: 0'); + expect(v2.slice(resetStart, v2.indexOf('}', resetStart))).toContain('letter-spacing: 0'); + // A surface lifted out of v2.css (D223) carries its own resets, so read + // every `:lang(zh)` rule that zeroes letter-spacing, not only the shared list. + const resetBlock = [...v2.matchAll(/([^{}]*)\{([^{}]*)\}/g)] + .filter(([, sels, body]) => sels.includes(':lang(zh)') && /letter-spacing:\s*0\b/.test(body)) + .map(([, sels]) => sels).join('\n'); for (const sel of negative) { const bare = sel.replace(/^\.v2-root /, ''); expect(resetBlock).toContain(`.v2-root:lang(zh) ${bare}`); @@ -2350,17 +2395,20 @@ describe('v2 layout invariants (CSS rule presence)', () => { // Measured in a real browser: .v2-msg__content rendered Chinese at 1.55, // the composer hint at 1.45/11px. CJK glyphs fill the em box, so Latin // leading leaves no white between lines and 11px is below legibility. - const lhStart = v2.indexOf('.v2-root:lang(zh) .v2-msg__content,'); - expect(lhStart).toBeGreaterThan(-1); - const lhBlock = v2.slice(lhStart, v2.indexOf('}', lhStart)); - expect(lhBlock).toContain('line-height: 1.6'); + // Each selector's zh rule, wherever its surface file put it: the D223 + // lifts split the shared :lang(zh) lists by surface. + const zhBlock = (sel: string): string => { + const at = v2.search(new RegExp(`\\.v2-root:lang\\(zh\\) ${sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[,\\s{])`)); + expect(at).toBeGreaterThan(-1); + return v2.slice(at, v2.indexOf('}', at)); + }; for (const sel of ['.v2-msg__content', '.v2-chat__composer-hint']) { - expect(lhBlock).toContain(`.v2-root:lang(zh) ${sel}`); + expect(zhBlock(sel)).toContain('line-height: 1.6'); } expect(ruleBody(v2, '.v2-root:lang(zh) .v2-workspace-inspector__empty')).toContain('line-height: 1.6'); - const floorStart = v2.indexOf('.v2-root:lang(zh) .v2-workspace-inspector__actor,\n.v2-root:lang(zh) .v2-workspace-inspector__task-meta'); - expect(floorStart).toBeGreaterThan(-1); - expect(v2.slice(floorStart, v2.indexOf('}', floorStart))).toContain('font-size: 12px'); + for (const sel of ['.v2-workspace-inspector__actor', '.v2-workspace-inspector__task-meta']) { + expect(zhBlock(sel)).toContain('font-size: 12px'); + } }); // Connectors v2 (spec §5): platform tint tokens must exist in BOTH @@ -2995,7 +3043,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { // the ≤760 act override is a BUTTON rule, and a row-level geometry rule in // there would outrank the shared rule exactly where it matters (390). const phone = mediaBlockContaining(v2, '.v2-chat__messages'); - expect(phone).toContain('padding-inline: 14px'); + // S3b: D215 .app.m .stream is 8 12 4. + expect(phone).toContain('padding: 8px 12px 4px'); expect(phone).not.toMatch(/\.v2-chat__(?:delivery-hint|no-agents|starts\b)/); }); @@ -3080,7 +3129,7 @@ describe('the landing hero demo (TASK-147)', () => { expect(ruleBody(demo, '.v2-demo__stage')).toContain('height: 720px'); expect(ruleBody(demo, '.v2-demo__stage')).toContain('background: var(--v2-page-bg)'); // The demo is the product's page, so it sits on the app's canvas colour. - expect(cssVariable(read('../v2.css'), '--v2-page-bg')).toBe('#eef0f4'); + expect(cssVariable(readV2Css(), '--v2-page-bg')).toBe('#eef0f4'); }); test('a phone stacks the columns and lets the stage grow instead of clipping them', () => { @@ -3198,7 +3247,7 @@ describe('the landing hero demo (TASK-147)', () => { // jsdom cannot see this: the row is a 140px / minmax(150px, 1fr) / 120px // grid, so without the span the message would be squeezed into the name or // act track instead of reading as one line — the TASK-029 failure mode. - const v2 = read('../v2.css'); + const v2 = readV2Css(); const refusal = ruleBody(v2, '.v2-connector-row__refusal'); expect(refusal).toContain('grid-column: 1 / -1'); expect(refusal).toContain('overflow-wrap: anywhere'); @@ -3239,7 +3288,7 @@ describe('the landing hero demo (TASK-147)', () => { // this item exists to remove. // --------------------------------------------------------------------------- describe('Bring your own agent onto Signal (TASK-166)', () => { - const v2 = read('../v2.css'); + const v2 = readV2Css(); const byoCss = read('../components/V2AgentBYO.css'); const byo = read('../components/V2AgentBYO.tsx'); const en = read('../../i18n/locales/en.json'); @@ -3477,7 +3526,7 @@ describe('Bring your own agent onto Signal (TASK-166)', () => { // heights ux-lead measured (359.1/375.0 at 1200, 214.5/223.9 at 390) are a // browser's answer and jsdom has no layout engine, so they are not asserted. describe('the BYO after-submit surfaces onto Signal (TASK-169)', () => { - const v2 = read('../v2.css'); + const v2 = readV2Css(); const byoCss = read('../components/V2AgentBYO.css'); const byo = read('../components/V2AgentBYO.tsx'); const en = read('../../i18n/locales/en.json'); @@ -3690,7 +3739,7 @@ describe('the BYO after-submit surfaces onto Signal (TASK-169)', () => { // this block walks the descendant axis for class-only compounds, and treats `>` // as descendant; every chain it is asked about is single-child, so the two agree. describe('the authenticated shell keeps its panes inside the banner row (TASK-157)', () => { - const v2 = read('../v2.css'); + const v2 = readV2Css(); type ParsedRule = { selector: string; @@ -5002,7 +5051,7 @@ describe('BYO command boxes wrap at spaces', () => { // (browser check on #2289). overflow-wrap still breaks a word that cannot // fit (the key, a URL), so a 390 screen never clips; it just stops // splitting words that could have moved to the next line whole. - const v2 = read('../v2.css'); + const v2 = readV2Css(); const byoCss = read('../components/V2AgentBYO.css'); test('the command box breaks inside a word only when the word cannot fit', () => { const rule = ruleBody(byoCss, '.v2-feature--byo .v2-byo__pre'); @@ -5028,7 +5077,7 @@ describe('BYO command boxes wrap at spaces', () => { }); describe('GitHub gig card fits the chat column', () => { - const css = read('../v2.css'); + const css = readV2Css(); test('bounds card width, wraps long repository text, and releases the avatar inset on phones', () => { const card = ruleBody(css, '.v2-gig'); expect(card).toContain('min-width: 0'); @@ -5121,7 +5170,7 @@ describe('agent Memory tab (TASK-732 lift of d215 #s-memory)', () => { }); describe('task priority and lease signals (TASK-443)', () => { - const css = read('../v2.css'); + const css = readV2Css(); test('priority remains a monochrome text badge', () => { const badge = ruleBody(boardCss, '.v2-feature--board .v2-board__priority'); expect(badge).toContain('var(--v2-font-mono)'); @@ -5178,7 +5227,7 @@ describe('task priority and lease signals (TASK-443)', () => { test('TASK-443 phone ownership sheet keeps its inset and scroll containment', () => { - const css = read('../v2.css'); + const css = readV2Css(); const sheet = ruleBody(boardCss, '.v2-board .v2-board__detail'); expect(sheet).toContain('inset: 44px 0 0'); const modal = ruleBody(css, '.v2-modal'); @@ -5260,7 +5309,7 @@ test('teammate invite cards keep one-column phone controls at the 44px minimum', }); test('standalone teammate invite button keeps desktop and phone touch targets', () => { - const css = read('../v2.css'); + const css = readV2Css(); const standalone = ruleBody(css, '.v2-chat__new-pod-action--invite > .v2-chat__new-pod-copy'); expect(standalone).toContain('min-height: 32px'); expect(standalone).toContain('margin-top: auto'); @@ -5375,21 +5424,21 @@ describe('v2 board on a phone (TASK-576)', () => { }); test('feature panes reserve the tab bar height so the body stops above the fixed nav', () => { - const css = read('../v2.css'); + const css = readV2Css(); const phone = mediaBlockContaining(css, 'padding-bottom: 58px'); expect(ruleBody(phone, '.v2-feature')).toContain('padding-bottom: 58px'); expect(ruleBody(phone, '.v2-mobile-tabs')).toContain('height: 58px'); }); test('hosted meter track keeps its flex width and clips the fill inside the card', () => { - const v2 = read('../v2.css'); + const v2 = readV2Css(); expect(ruleBody(v2, '.v2-team-card__meter-track')).toContain('flex: 1'); expect(ruleBody(v2, '.v2-team-card__meter-track')).toContain('min-width: 0'); expect(ruleBody(v2, '.v2-team-card__meter-track')).toContain('overflow: hidden'); }); test('character avatars keep their hairline ring rule (TASK-679, load-bearing)', () => { - expect(read('../v2.css')).toMatch( + expect(readV2Css()).toMatch( /\.v2-avatar:has\(img\)\.v2-avatar--character:not\(\.v2-avatar--flat\)\s*\{\s*box-shadow:\s*0 0 0 1px var\(--v2-avatar-frame\);/, ); }); @@ -5405,16 +5454,21 @@ describe('v2 board on a phone (TASK-576)', () => { describe('claim run summary (TASK-692)', () => { test('the toggle is a real button styled as a control, with its state on aria-expanded', () => { - const css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8'); + const css = readV2Css(); const toggle = css.match(/\.v2-root button\.v2-claim-run__toggle \{[^}]*\}/); expect(toggle).not.toBeNull(); - expect(toggle![0]).toMatch(/box-shadow: inset 0 0 0 1px var\(--v2-border\)/); - expect(css).toMatch(/\.v2-claim-run__toggle\[aria-expanded='true'\]::after/); + // S3a: the D215 .sumrow bar — 34px, r9, subtle fill, soft inset ring. + expect(toggle![0]).toMatch(/box-shadow: inset 0 0 0 1px var\(--v2-border-soft\)/); + expect(toggle![0]).toContain('height: 34px'); + expect(toggle![0]).toContain('background: var(--v2-bg-subtle)'); + expect(css).toMatch(/\.v2-claim-run__toggle\[aria-expanded='true'\] > \.v2-claim-run__chev \{ transform: rotate\(180deg\); \}/); + // The chevron is a state mark: text-muted (4.76:1 on the bar), not --ph (2.46:1). + expect(ruleBody(css, '.v2-claim-run__toggle > .v2-claim-run__chev')).toContain('color: var(--v2-text-muted)'); }); }); describe('pod board column scroll (TASK-698)', () => { - const css = read('../v2.css'); + const css = readV2Css(); test('the board route scrolls inside its columns, not the page', () => { expect(css).not.toContain('.v2-feature--board'); // The feature body was the scroller and its legacy wrapper had auto height, so the @@ -5442,22 +5496,22 @@ describe('pod board column scroll (TASK-698)', () => { // TASK-702: on a phone the header Invite is glyph-only, or its label takes // the pod name's width and the title truncates to a few characters. test('the header Invite hides its label on a phone, keeping it for screen readers', () => { - const block = mediaBlockContaining(read('../v2.css'), '.v2-pod-header__invite span'); + const block = mediaBlockContaining(readV2Css(), '.v2-pod-header__invite span'); expect(block).toMatch(/\.v2-pod-header__invite span\s*\{[^}]*clip-path:\s*inset\(50%\)/); expect(block).toMatch(/\.v2-pod-header__invite\s*\{[^}]*width:\s*32px/); // The ghost override drops the key's shadow, so the phone restates the focus ring. expect(block).toContain('.v2-root button.v2-pod-header__invite:focus-visible { box-shadow: 0 0 0 3px var(--v2-accent-soft), 0 0 0 1px var(--v2-accent); }'); // 32px keys take touches at 44px. The inset runs from the padding box, // inside a 1px border, so it is -7px: 30 + 2 x 7 = 44 (-6px measured 42 in a browser). - const phoneHead = mediaBlockContaining(read('../v2.css'), '.v2-root button.v2-thread__back::after'); - for (const key of ['v2-thread__back', 'v2-pod-header__invite', 'v2-thread__inspector-toggle', 'v2-composer__plus', 'v2-composer__send']) { - expect(phoneHead).toContain(`.v2-root button.${key}::after`); - } - expect(phoneHead).toMatch(/::after \{\s*content: '';\s*position: absolute;[^}]*inset: -7px;\s*\}/); - // z-index keeps every key's hit area above later positioned siblings. - const raised = phoneHead.match(/([^{}]*)\{ position: relative; z-index: 1; \}/); - expect(raised).not.toBeNull(); + // The header keys and the composer keys shared one rule until D223 lifted + // them into separate chat files (S3b, S3c); each half keeps the same shape. + const phoneHead = mediaBlockContaining(readV2Css(), '.v2-root button.v2-thread__back::after'); for (const key of ['v2-thread__back', 'v2-pod-header__invite', 'v2-thread__inspector-toggle', 'v2-composer__plus', 'v2-composer__send']) { + const keyBlock = mediaBlockContaining(readV2Css(), `.v2-root button.${key}::after`); + expect(keyBlock).toMatch(/::after \{\s*content: '';\s*position: absolute;[^}]*inset: -7px;\s*\}/); + // z-index keeps every key's hit area above later positioned siblings. + const raised = keyBlock.match(/([^{}]*)\{ position: relative; z-index: 1; \}/); + expect(raised).not.toBeNull(); expect(raised![1]).toContain(`.v2-root button.${key}`); } // The 12px gap is what keeps neighbouring 44px areas from overlapping. @@ -5486,3 +5540,29 @@ describe('pod board column scroll (TASK-698)', () => { expect(mobile).toMatch(/\.v2-board__tab\.is-active \{ background: var\(--v2-surface\)/); }); }); + +describe('chat sheets lifted out of v2.css (S3)', () => { + const chatSheets = ['chat-message', 'chat-pane', 'chat-composer', 'chat-inspector', 'chat-states'] + .map((name) => read(`../chat/${name}.css`)); + + test('the rules the landing demo renders stay in v2.css, which is all the landing loads', () => { + const v2Only = read('../v2.css'); + expect(v2Only).toMatch(/\n\.v2-msg \{/); + expect(v2Only).toMatch(/\n\.v2-decision-card__options \{/); + expect(v2Only).toMatch(/\n\.v2-decision-card__option \{/); + // Selectors only: a comment may name the class to say it stayed behind. + for (const sheet of chatSheets) expect(sheet.replace(/\/\*[\s\S]*?\*\//g, '')).not.toContain('.v2-decision-card'); + }); + + test('the phone thread foot keys take touches at 44px, measured inside their 1px border', () => { + expect(chatSheets[1]).toContain(".v2-root .v2-thread-replies__foot .v2p-b-sm::after { content: ''; position: absolute; inset: -7px; }"); + }); + + test('a UI card posted as a thread reply keeps its own stack gap', () => { + expect(chatSheets[1]).toContain('.v2-thread .v2-thread-replies .v2-msg.v2-ui-card { gap: 4px; }'); + }); + + test('every token the chat sheets read is defined', () => { + for (const sheet of chatSheets) expect(sheet).not.toMatch(/var\(--v2-text\)/); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-type-floors.test.ts b/frontend/src/v2/__tests__/v2-type-floors.test.ts index c86b01f5d..25096dd8a 100644 --- a/frontend/src/v2/__tests__/v2-type-floors.test.ts +++ b/frontend/src/v2/__tests__/v2-type-floors.test.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { readV2Css } from '../__testkit__/v2Css'; /** * Signal's type floors (ruling h, 2026-09-06), enforced over the whole @@ -24,10 +25,10 @@ import path from 'path'; * fixed one fails the second (the allowlist may not carry a selector that no * longer violates). Target is an empty list. */ -// v2.css plus the per-surface sheets lifted out of it, so a rule that moved -// file is still held to the floors (R04 moved the BYO page's rules, S4 the board's). -const CSS = ['../v2.css', '../components/V2AgentBYO.css', '../components/V2PodBoard.css'] - .map((rel) => fs.readFileSync(path.join(__dirname, rel), 'utf8')) +// v2.css plus the surface files lifted out of it (D223): the chat sheets V2App +// loads, and the per-surface sheets their components import (R04 BYO, S4 board). +const CSS = [readV2Css(), ...['../components/V2AgentBYO.css', '../components/V2PodBoard.css'] + .map((rel) => fs.readFileSync(path.join(__dirname, rel), 'utf8'))] .join('\n'); const MONO_FLOOR = 11; @@ -117,7 +118,9 @@ const ALLOWLIST: string[] = [ '.v2-chat__composer-hint', // 11.0px — no-mono-family '.v2-chat__composer-hint kbd', // 10.5px — mono-below-floor '.v2-chat__mode-toggle--header .v2-chat__mode-option', // 11.5px — no-mono-family - '.v2-chat__new-pod-action-text, .v2-chat__new-pod-status', // 11.0px — no-mono-family + // Split in two by the D223 chat lift (S3d): the status half moved to chat/chat-states.css. + '.v2-chat__new-pod-action-text', // 11.0px — no-mono-family + '.v2-chat__new-pod-status', // 11.0px — no-mono-family '.v2-chat__new-pod-error', // 11.0px — no-mono-family '.v2-chat__title-mark', // 11.0px — no-mono-family '.v2-msg__file-icon', // 10.0px — no-mono-family diff --git a/frontend/src/v2/chat/chat-composer.css b/frontend/src/v2/chat/chat-composer.css new file mode 100644 index 000000000..701755096 --- /dev/null +++ b/frontend/src/v2/chat/chat-composer.css @@ -0,0 +1,279 @@ +/* Pod chat · composer and mention picker (S3c, D223 lift of the d215 chat + frame). + + Source: docs/design/artboards/d215/index.html, data-surface="chat" at 1440 + and 390, product CSS `.comp`, `.comp .plus`, `.comp .send`, `.app.m .comp`. + Token map as in chat-message.css. Imported by V2App.tsx after + chat-pane.css. */ + +/* @-mention dropdown anchored to the composer input wrap. Sits above the + composer in DOM order but renders above-and-out via absolute positioning. */ +.v2-mention-dropdown { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 6px); + z-index: 10; + max-height: 240px; + overflow-y: auto; + background: var(--v2-surface); + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.v2-mention-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 9px; + border-radius: var(--v2-radius-sm); + background: transparent; + border: none; + text-align: left; + cursor: pointer; + width: 100%; + font: inherit; + color: var(--v2-text-primary); +} + +.v2-mention-item:hover, +.v2-mention-item--active { + background: var(--v2-surface-hover); +} + +/* Keep an explicit root/button selector for the interaction states. The reset + is a low-specificity default now, but these rules document the intended + highlight instead of relying on source order or browser defaults. */ +.v2-root button.v2-mention-item:hover { + background: var(--v2-surface-hover); +} + +.v2-root button.v2-mention-item--active { + background: var(--v2-surface-hover); +} + +/* The dropdown owns the item's 7px 9px padding explicitly. */ +.v2-root .v2-mention-dropdown button.v2-mention-item { + padding: 7px 9px; +} + +.v2-mention-item__text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.v2-mention-item__label { + font-size: 13px; + font-weight: 600; + color: var(--v2-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-mention-item__sub { + font-size: 11px; + color: var(--v2-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* TASK-702: the header Invite. Same height and frame as the inspector + toggle beside it, with a label so it reads as an action, not decoration. */ +.v2-composer__error { + margin: 0; + padding: 0 10px 8px; + color: #b91c1c; + font: 400 12px/16px var(--v2-font); +} + +.v2-composer__warnings { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 10px 8px; + color: var(--v2-text-secondary); + font: 400 12px/16px var(--v2-font); +} + +@media (max-width: 760px) { + /* The phone keys draw at 32px and take touches at 44px (D223 standing + rule, 11-01): a pseudo-element extends the hit area, the box stays. + z-index keeps it above later positioned siblings such as the transcript. */ + .v2-root button.v2-composer__plus, + .v2-root button.v2-composer__send { position: relative; z-index: 1; } + .v2-root button.v2-composer__plus::after, + .v2-root button.v2-composer__send::after { + content: ''; + position: absolute; + /* From the padding box, inside each key's 1px border: 30 + 2 x 7 = 44. */ + inset: -7px; + } +} + +.v2-composer { + flex: 0 0 auto; + /* D215 (TASK-734): a floating card, radius 14 on the pop shadow, no border. */ + margin: 4px 18px 16px; + border: 0; + border-radius: var(--v2-r-14, 14px); + background: var(--v2-surface); + box-shadow: var(--v2-shadow-pop, 0 0 0 1px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04), 0 18px 36px -14px rgba(16, 24, 40, 0.28)); +} + +/* Typing or focused, a darker hairline sits over the card's own; the drop stays. */ +.v2-composer--typing, +.v2-composer:focus-within { + box-shadow: 0 0 0 1px var(--v2-border-strong), var(--v2-shadow-pop, 0 18px 36px -14px rgba(16, 24, 40, 0.28)); +} + +.v2-composer__row { + display: flex; + align-items: flex-end; + gap: 8px; + min-height: 52px; + padding: 10px; +} + +.v2-composer__plus-wrap { position: relative; flex: 0 0 auto; align-self: flex-end; } + + +/* Look from the D223 primitives (primitives.css): v2p-b v2p-b-g v2p-ib; the open menu keeps the hover tint. */ +.v2-root button.v2-composer__plus[aria-expanded="true"] { background: var(--v2-surface-tint); color: var(--v2-text-primary); } + +/* Disabled while a file uploads: the primitive's ghost hover has no :disabled guard. */ +.v2-root button.v2-composer__plus:disabled { cursor: progress; } + +.v2-root button.v2-composer__plus:disabled:hover { background: transparent; color: var(--v2-text-secondary); } + +.v2-composer__menu { + position: absolute; + left: 0; + bottom: 40px; + z-index: 4; + display: flex; + min-width: 220px; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 0; + border-radius: var(--v2-r-10, 10px); + background: var(--v2-surface); + box-shadow: var(--v2-shadow-pop, 0 0 0 1px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04), 0 18px 36px -14px rgba(16, 24, 40, 0.28)); +} + +.v2-root .v2-composer__menu > button { + display: block; + width: 100%; + min-height: 32px; + padding: 6px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-primary); + font: 400 13px/18px var(--v2-font); + text-align: left; +} + +.v2-root .v2-composer__menu > button:hover, +.v2-root .v2-composer__menu > button:focus-visible { background: var(--v2-surface-hover); outline: none; } + +.v2-composer__tag { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + max-width: 220px; + padding: 2px 4px 2px 6px; + margin-bottom: 4px; + border-radius: 3px; + background: var(--v2-accent-soft, #e8ecfb); + color: var(--v2-accent-text); + font: 500 11px/16px var(--v2-font-mono); + text-transform: lowercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-root button.v2-composer__tag-cancel { + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; +} + +.v2-composer__field { position: relative; min-width: 0; flex: 1; } + +.v2-composer__file { display: none; } + +/* Ruling 7: the frame's --ph measures 2.58:1 on the surface (dark 3.17:1), + under 4.5:1 for text; muted measures 4.97:1 (dark 5.77:1). */ +.v2-root .v2-composer__field > textarea::placeholder { color: var(--v2-text-muted); } + +/* The row carries the focus state on its border; the field itself draws none. */ +.v2-root .v2-composer__field > textarea:focus, +.v2-root .v2-composer__field > textarea:focus-visible { + outline: none; + box-shadow: none; + border: 0; +} + +.v2-root .v2-composer__field > textarea { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 32px; + max-height: 112px; + padding: 6px 4px; + resize: none; + border: 0; + outline: 0; + background: transparent; + color: var(--v2-text-primary); + font: 400 14px/20px var(--v2-font); +} + +.v2-composer__uploading { + flex: 0 0 auto; + align-self: center; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); +} + +/* Look from the D223 primitives (primitives.css): v2p-b v2p-b-p v2p-ib, with + the frame's up-arrow glyph. Empty, Send is disabled but drawn as at rest + (D215); while a send is in flight it keeps the old progress look. */ +.v2-root button.v2-composer__send { + flex: 0 0 32px; + margin-bottom: 0; +} + +.v2-root button.v2-composer__send:disabled { cursor: default; } + +.v2-root .v2-composer--typing button.v2-composer__send:disabled { cursor: progress; opacity: 0.6; } + +.v2-composer__error, +.v2-composer__warnings { padding: 0 10px 8px; } + +@media (max-width: 760px) { + /* Sits above the 56px tab bar; the row itself keeps its desktop shape. */ + .v2-composer { margin: 4px 10px 64px; } + .v2-composer__menu { min-width: 200px; } + .v2-composer__tag { max-width: 120px; } +} + +/* Composer aim chip (walk-3 miss 59). */ +.v2-composer__aim { background: var(--v2-accent-soft, #e8ecfb); color: var(--v2-accent-text); } diff --git a/frontend/src/v2/chat/chat-inspector.css b/frontend/src/v2/chat/chat-inspector.css new file mode 100644 index 000000000..eba90b807 --- /dev/null +++ b/frontend/src/v2/chat/chat-inspector.css @@ -0,0 +1,432 @@ +/* Pod chat · the pod inspector (S3c, D223 lift of the d215 chat frame and + the #3176 inspector frame). + + Source: docs/design/artboards/d215/index.html, data-surface="chat" at + 1440, product CSS `.insp`, `.insp .ih`, `.insp .row`, `.grp`. Token map as + in chat-message.css. Imported by V2App.tsx after chat-composer.css. */ + +.v2-pane--inspector { + border-right: none; + border-left: 1px solid var(--v2-border-soft); + background: var(--v2-shell-bg); +} + +.v2-pod-join-settings { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + margin: 3px 0; + padding: 0; + border: 0; + color: var(--v2-text-secondary); + font: 400 12px/16px var(--v2-font); +} + +.v2-pod-join-settings legend { + margin-bottom: 6px; + color: var(--v2-text-primary); + font-weight: 600; +} + +.v2-pod-join-settings label { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + cursor: pointer; +} + +.v2-pod-join-settings input { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + flex-shrink: 0; + border: 1.5px solid var(--v2-control-outline); + border-radius: 50%; + background: var(--v2-surface); + cursor: pointer; +} + +.v2-pod-join-settings input:checked { + border: 4px solid var(--v2-accent); +} + +.v2-pod-join-settings__error { + margin: 8px 0 0; + color: var(--v2-danger); + font: 400 12px/16px var(--v2-font); +} + +@media (max-width: 1023px) { + /* Below the 3-pane width the inspector becomes a right DRAWER, not a + casualty. Hiding the pane outright here made the avatar-stack button a + no-op on phones: V2Layout mounts the pane on tap, CSS hid it, and the + pod's members/files/artifacts were unreachable on mobile entirely + (2026-08-26). The pane only exists in the DOM while open + (V2Layout mounts it conditionally), so its presence IS the open state; + the existing v2-inspector__close dismisses it. Shadow is allowed here: + the design system reserves shadows for floating UI, which this now is. */ + .v2-pane--inspector { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(360px, calc(100vw - 48px)); + z-index: 60; + border-left: 1px solid var(--v2-border); + box-shadow: -12px 0 32px rgba(17, 24, 39, 0.14); + } +} + +@media (max-width: 760px) { + /* The inspector is a bottom sheet on phones, not a narrow desktop drawer. */ + .v2-pane--inspector { + top: auto; + left: 0; + width: 100%; + max-height: min(78dvh, 680px); + border: 1px solid var(--v2-border); + border-bottom: 0; + border-radius: 6px 6px 0 0; + box-shadow: 0 -12px 32px rgba(17, 24, 39, 0.14); + } +} + +.v2-workspace-inspector { + position: relative; + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; + gap: 10px; + overflow-y: auto; + background: var(--v2-shell-bg); +} + +/* The new inspector is a card stack on the shell ground, not a second pane + delineated by a desktop divider. Its cards provide the only borders. */ +.v2-pane--inspector { + border-left: 0; +} + +.v2-workspace-inspector__card { + min-width: 0; + border: 0; + border-radius: var(--v2-r-14, 14px); + background: var(--v2-surface); + box-shadow: var(--v2-shadow-pane, 0 0 0 1px var(--v2-border-soft)); + padding: 12px; +} + +.v2-workspace-inspector__label { + display: block; + margin: 0; + color: var(--v2-text-primary); + font: 600 13px/18px var(--v2-font); + white-space: nowrap; +} + +/* ---------- D215 panel fold (TASK-691) ---------- + Needs you, Agents, Files and Who can join are each one 44px row at rest and + open on demand. The section titles lost their mono captions; an icon and a + count say the same thing. Members and manage are icon buttons in the head. */ +.v2-workspace-inspector__head { + display: flex; + min-height: 32px; + flex: none; + align-items: center; + gap: 6px; + /* D215 (TASK-734): Pod | Threads on the left, the icon keys on the right. + S3c: the frame's .ihead pads 8 10 all round. */ + padding: 8px 10px; +} + +.v2-workspace-inspector__head-spacer { flex: 1; } + +/* Hide panel is the desktop key; the phone sheet keeps its close X. The key + is a primitive (.v2p-b, 0,1,0), so .v2-root button. outranks its + display. */ +@media (max-width: 760px) { + .v2-root button.v2-workspace-inspector__hide { display: none; } +} + +.v2-workspace-inspector__threads { display: grid; gap: 2px; align-content: start; } + +.v2-root button.v2-workspace-inspector__thread { + display: grid; + width: 100%; + min-width: 0; + gap: 2px; + padding: 8px 10px; + border: 0; + border-radius: var(--v2-r-8, 8px); + background: transparent; + color: var(--v2-text-primary); + text-align: left; + cursor: pointer; +} + +.v2-root button.v2-workspace-inspector__thread:hover { background: var(--v2-surface-tint); } + +.v2-root button.v2-workspace-inspector__thread:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); } + +.v2-workspace-inspector__thread-head { display: flex; align-items: baseline; gap: 8px; min-width: 0; } + +.v2-workspace-inspector__thread-author { font: 600 13px/18px var(--v2-font); } + +.v2-workspace-inspector__thread-when { margin-left: auto; color: var(--v2-text-muted); font: 500 11px/16px var(--v2-font-mono); } + +.v2-workspace-inspector__thread-text { + overflow: hidden; + color: var(--v2-text-secondary); + font: 400 12.5px/18px var(--v2-font); + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-inspector__card.v2-workspace-inspector__group { flex: none; padding: 0; overflow: hidden; } + +.v2-root button.v2-workspace-inspector__fold { + display: flex; + width: 100%; + min-width: 0; + height: 44px; + align-items: center; + gap: 9px; + padding: 0 12px 0 14px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--v2-text-primary); + font: 600 13.5px/18px var(--v2-font); + text-align: left; + cursor: pointer; +} + +.v2-root button.v2-workspace-inspector__fold:hover { background: var(--v2-surface-hover); } + +.v2-root button.v2-workspace-inspector__fold:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--v2-accent); } + +.v2-workspace-inspector__fold-icon { display: grid; flex: none; place-items: center; color: var(--v2-text-secondary); } + +.v2-workspace-inspector__fold-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } + +.v2-workspace-inspector__fold-count { color: var(--v2-text-muted); font: 500 11.5px/1 var(--v2-font-mono); } + +.v2-workspace-inspector__fold-stack { display: flex; margin-left: auto; padding-left: 6px; } + +.v2-workspace-inspector__fold-avatar.v2-avatar { + width: 24px; + height: 24px; + margin-left: -6px; + border: 2px solid var(--v2-surface); + border-radius: 6px; + box-shadow: none; +} + +.v2-workspace-inspector__fold-chevron { + display: grid; + flex: none; + margin-left: auto; + place-items: center; + /* Ruling 7: the frame's --ph chevron measures 2.58:1 on the surface (dark + 3.17:1), under 3:1 for a mark; muted measures 4.97:1 (dark 5.77:1). */ + color: var(--v2-text-muted); + transition: transform 160ms; +} + +.v2-workspace-inspector__fold-stack + .v2-workspace-inspector__fold-chevron { margin-left: 0; } + +.v2-workspace-inspector__fold[aria-expanded="true"] .v2-workspace-inspector__fold-chevron { transform: rotate(180deg); } + +.v2-workspace-inspector__group-body { padding: 0 14px 12px; } + +.v2-workspace-inspector__group-body .v2-pod-join-settings { margin: 0; padding: 0; border: 0; } + +.v2-workspace-inspector__rows { + display: flex; + min-width: 0; + flex-direction: column; + /* D215 .gbody: rows 2px apart. */ + gap: 2px; +} + +.v2-root button.v2-workspace-inspector__agent, +.v2-root button.v2-workspace-inspector__attention, +.v2-root button.v2-workspace-inspector__task { + width: 100%; + min-width: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-primary); + text-align: left; +} + +.v2-root button.v2-workspace-inspector__agent { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 8px; + align-items: center; + gap: 8px; + padding: 6px 4px; +} + +.v2-root button.v2-workspace-inspector__agent:hover, +.v2-root button.v2-workspace-inspector__attention:hover, +.v2-root button.v2-workspace-inspector__task:hover { + background: var(--v2-surface-hover); +} + +.v2-workspace-inspector__avatar.v2-avatar { + width: 28px; + height: 28px; + border: 0; + border-radius: 4px; + box-shadow: none; + font-size: 12px; +} + +.v2-workspace-inspector__agent-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.v2-workspace-inspector__agent-name { + overflow: hidden; + color: var(--v2-text-primary); + font: 600 13px/18px var(--v2-font); + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-inspector__agent-status { + overflow: hidden; + color: var(--v2-text-muted); + font: 400 12px/16px var(--v2-font); + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-inspector__state { + width: 8px; + height: 8px; + border-radius: 0; + background: var(--v2-text-muted); +} + +.v2-workspace-inspector__state--needs-you { background: var(--v2-accent); } + +.v2-workspace-inspector__state--working { + background: var(--v2-accent); + animation: v2-pulse var(--motion-pulse) var(--motion-ease-state) infinite; +} + +.v2-root button.v2-workspace-inspector__attention, +.v2-root button.v2-workspace-inspector__task { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 7px 4px; + font: 400 13px/18px var(--v2-font); +} + +.v2-workspace-inspector__attention > span:first-child, +.v2-workspace-inspector__attention-title, +.v2-workspace-inspector__task > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-inspector__actor, +.v2-workspace-inspector__task-meta { + flex-shrink: 0; + color: var(--v2-accent-text); + font: 500 11px/14px var(--v2-font-mono); +} + +.v2-workspace-inspector__task-meta { color: var(--v2-text-muted); } + +/* D215 .nrow: a Needs-you item is a 32px row in ink-2 with an inbox glyph. + The frame's P1/P2 badge has no data behind it (V2AttentionItem carries no + priority), so the agent name keeps that slot. The "N need you" link row + above the items is not drawn and keeps its look. */ +.v2-root button.v2-workspace-inspector__attention--item { + align-items: center; + justify-content: flex-start; + height: 32px; + padding: 0 4px; + color: var(--v2-ink-2); +} + +.v2-workspace-inspector__attention--item > svg { flex: none; } + +.v2-workspace-inspector__attention-title { flex: 1; } + +.v2-workspace-inspector__empty { + margin: 0; + padding: 4px; + color: var(--v2-text-muted); + font: 400 12px/16px var(--v2-font); +} + +.v2-root button.v2-workspace-inspector__close { + display: none; + width: 28px; + height: 28px; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-secondary); +} + +.v2-root button.v2-workspace-inspector__close:hover { background: var(--v2-surface-hover); color: var(--v2-text-primary); } + +.v2-root button.v2-workspace-inspector__close:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); } + +@media (max-width: 760px) { + .v2-workspace-inspector { padding: 12px 14px 0; } + .v2-root button.v2-workspace-inspector__close { display: inline-grid; } +} + +/* The new factual inspector keeps CJK metadata at the established readable + floor. These rows are the artboard's compact mono labels, not body prose. */ +.v2-root:lang(zh) .v2-workspace-inspector__empty { line-height: 1.6; } + +.v2-root:lang(zh) .v2-workspace-inspector__actor, +.v2-root:lang(zh) .v2-workspace-inspector__task-meta { font-size: 12px; } + +@media (max-width: 760px) { + /* The inspector becomes the artboard's bottom sheet; its header toggle is + intentionally always present so the sheet cannot become unreachable. */ + .v2-pane--inspector { + top: auto; + right: 0; + bottom: 0; + width: 100%; + height: min(76dvh, 620px); + border: 1px solid var(--v2-border); + border-bottom: 0; + border-radius: 12px 12px 0 0; + box-shadow: 0 -12px 32px rgba(17, 24, 39, 0.14); + } +} + +/* Inspector Files pane: the same rows as needs-you, an ext chip in front, All N files as cobalt text. */ +.v2-root button.v2-workspace-inspector__file { display: flex; align-items: center; justify-content: flex-start; gap: 8px; width: 100%; padding: 7px 4px; border: 0; background: transparent; color: var(--v2-text-primary); font: 400 13px/18px var(--v2-font); text-align: left; } + +.v2-root button.v2-workspace-inspector__file > span:nth-child(2) { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.v2-root button.v2-workspace-inspector__file:hover { background: var(--v2-surface-hover); } + +.v2-workspace-inspector__ext { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 18px; padding: 0 4px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); color: var(--v2-text-secondary); font: 500 11px/14px var(--v2-font-mono); } + +.v2-root button.v2-workspace-inspector__all-files { padding: 6px 4px 2px; border: 0; background: transparent; color: var(--v2-accent-text); font: 600 12px/16px var(--v2-font); text-align: left; } diff --git a/frontend/src/v2/chat/chat-message.css b/frontend/src/v2/chat/chat-message.css new file mode 100644 index 000000000..74647d7ba --- /dev/null +++ b/frontend/src/v2/chat/chat-message.css @@ -0,0 +1,1110 @@ +/* Pod chat · message row (S3a, D223 lift of the d215 chat frame). + + Source: docs/design/artboards/d215/index.html, data-surface="chat" at 1440 + and 390, product CSS `.msg .mhead .mname .mmeta .mbody .cd .mn .fold + .showmore .acts .sumrow .sumlist`. Values are the frame's; the artboard's + short tokens map to v2 tokens as in V2AgentBYO.css (--sf surface, --p + bg-subtle, --t surface-tint, --d border-soft, --b border, --mu text-muted, + --se text-secondary, --ph text-placeholder, --ink text-primary, --ink2 + ink-2, --c accent, --c-text accent-text, --c-soft accent-soft, --sh-pop + shadow-pop, --f-mono font-mono). + + Imported by V2App.tsx AFTER v2.css and primitives.css, so these rules keep + the order they had at the end of v2.css. Rules the landing DemoWorkspace or + uiCard also use (.v2-msg, .v2-decision-card, ...) stay in v2.css. */ + +/* Sender-only delivery feedback sits under the post as a quiet system line. */ +.v2-msg__sender-notice { + color: var(--v2-text-tertiary); + font: 12px/16px var(--v2-font); + margin-top: 6px; +} + +.v2-root .v2-msg__sender-notice a { + color: var(--v2-accent); + text-decoration: underline; +} + +/* TASK-406: quiet, non-interactive kernel action line in the pod timeline. */ +.v2-system-action-line { + box-sizing: border-box; + width: calc(100% - 48px); + margin-left: 48px; + display: grid; + grid-template-columns: 16px minmax(0, max-content) auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 4px 0; + font: 400 13px/16px var(--v2-font); +} + +.v2-thread .v2-system-action-line .v2-avatar { + width: 16px; + height: 16px; + border-width: 1px; + font: 650 11px/1 var(--v2-font-mono); + letter-spacing: 0; + text-indent: 0; +} + +.v2-system-action-line__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--v2-text-primary); + font-weight: 500; +} + +.v2-system-action-line__action { + grid-column: 3; + display: flex; + gap: 5px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.v2-system-action-line__verb { + color: var(--v2-text-tertiary); +} + +.v2-system-action-line__object { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--v2-text-tertiary); + font: 400 11px/16px var(--v2-font-mono); +} + +.v2-system-action-line time { + grid-column: 5; + color: var(--v2-text-placeholder); + font: 400 11px/16px var(--v2-font-mono); +} + +/* D215 .sumrow: a run of claim lines folds to one 34px bar in the text + column: board glyph, up to four 20px faces, "N claims · first to last", + the run's time, a chevron. The toggle is a control with its state on + aria-expanded. */ +.v2-root button.v2-claim-run__toggle { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + width: calc(100% - 48px); + height: 34px; + margin: 4px 0 4px 48px; + padding: 0 10px; + border: 0; + border-radius: 9px; + background: var(--v2-bg-subtle); + color: var(--v2-text-secondary); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + font: 400 12.5px/1 var(--v2-font); + text-align: left; + cursor: pointer; +} + +/* not drawn in the frame yet: kept as it was */ +.v2-root button.v2-claim-run__toggle:hover { background: var(--v2-surface-hover); } + +.v2-root button.v2-claim-run__toggle:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); } + +.v2-claim-run__toggle > svg { flex: none; } + +.v2-claim-run__faces { display: inline-flex; flex: none; } + +.v2-thread .v2-claim-run__faces .v2-avatar { + width: 20px; + height: 20px; + border: 0; + border-radius: 6px; + box-shadow: 0 0 0 2px var(--v2-surface); + font: 650 11px/1 var(--v2-font-mono); +} + +.v2-thread .v2-claim-run__faces .v2-avatar + .v2-avatar { margin-left: -6px; } + +.v2-claim-run__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.v2-claim-run__label b { color: var(--v2-text-primary); font-weight: 600; } + +.v2-claim-run__time { flex: none; color: var(--v2-text-muted); font: 500 11.5px/1 var(--v2-font-mono); } + +/* The chevron takes text-muted, not the frame's --ph: --ph is 2.46:1 on the + bar, below the 3:1 a state mark needs; muted is 4.76:1 light, 5.48:1 dark. */ +.v2-claim-run__toggle > .v2-claim-run__chev { + display: inline-grid; + flex: none; + place-items: center; + margin-left: auto; + color: var(--v2-text-muted); + transition: transform 160ms; +} + +.v2-claim-run__toggle[aria-expanded='true'] > .v2-claim-run__chev { transform: rotate(180deg); } + +/* D215 .sumlist: the open run lists who took what in 26px rows. */ +.v2-claim-run__list { display: grid; gap: 2px; margin: 0 0 6px 48px; padding: 4px 10px; } + +.v2-claim-run__list .v2-system-action-line { + width: auto; + height: 26px; + margin-left: 0; + padding: 0; + grid-template-columns: 20px minmax(0, max-content) auto minmax(0, 1fr) auto; + color: var(--v2-text-secondary); + font: 400 12.5px/16px var(--v2-font); +} + +.v2-thread .v2-claim-run__list .v2-system-action-line .v2-avatar { width: 20px; height: 20px; border-radius: 6px; } + +.v2-claim-run__list .v2-system-action-line__name { font-weight: 600; } + +.v2-claim-run__list .v2-system-action-line__verb { color: var(--v2-text-secondary); } + +.v2-claim-run__list .v2-system-action-line__object { + padding: 2px 5px; + border-radius: 6px; + background: var(--v2-surface-tint); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + color: var(--v2-text-primary); + font: 500 11px/1 var(--v2-font-mono); +} + +.v2-claim-run__list .v2-system-action-line time { color: var(--v2-text-muted); font: 500 11px/16px var(--v2-font-mono); } + +@media (max-width: 760px) { + /* D215 390: the run sits on the 40px text column. */ + .v2-root button.v2-claim-run__toggle { width: calc(100% - 40px); margin-left: 40px; } + .v2-claim-run__list { margin-left: 40px; } +} + +/* §3.8 system event card — replaces the regular author/avatar bubble for + commonly-bot announcements like "Pixel and codex started a DM". One-row, + chrome-light, with a primary CTA that navigates internally. */ +.v2-msg.v2-msg--system { + /* Override the two-column avatar grid from .v2-msg: a system card is a + single child and would otherwise be crammed into the 38px avatar column, + collapsing the headline and wrapping the timestamp. Block = full width. + Selector MUST be `.v2-msg.v2-msg--system` (two classes): `.v2-msg` sets + `display: grid` LATER in this file, so an equal-specificity `.v2-msg--system` + loses on source order. Higher specificity wins regardless of order. */ + display: block; + padding: 6px 0; +} + +.v2-syscard { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--v2-surface-hover); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); +} + +.v2-syscard--action { + background: var(--v2-surface); + box-shadow: 0 0 0 2px var(--v2-accent); +} + +.v2-syscard__icon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--v2-radius-pill); + background: var(--v2-surface-hover); + color: var(--v2-ink); +} + +.v2-syscard__body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.v2-syscard__headline { + font-size: 13px; + font-weight: 600; + color: var(--v2-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-syscard__time { + font-size: 11.5px; + color: var(--v2-text-secondary); +} + +.v2-root button.v2-syscard__cta, +.v2-syscard__cta { + flex: 0 0 auto; + font-size: 12.5px; + font-weight: 600; + color: var(--v2-on-ink); + background: var(--v2-ink); + border: 1px solid var(--v2-ink); + border-radius: var(--v2-radius-pill); + padding: 6px 14px; + cursor: pointer; + transition: background 120ms ease; +} + +.v2-root button.v2-syscard__cta:hover, +.v2-syscard__cta:hover { + background: var(--v2-ink-hover); +} + +/* Consecutive-author grouping (craft baseline rule 3): grouped rows drop + the 22px head + avatar and tighten padding, so a run of messages from one + author reads as one block. The ghost keeps the grid's avatar column so + text alignment never shifts. Reply and Thread become hover affordances + pinned to the row's top-right, since the head row is gone. */ +.v2-msg--grouped { padding: 2px 0; } + +.v2-msg__avatar-ghost { width: 38px; } + +.v2-msg__actions { + position: absolute; + top: -12px; + right: 8px; + display: flex; + align-items: center; + gap: 1px; + padding: 2px; + background: var(--v2-surface); + border: 1px solid var(--v2-border); + border-radius: 8px; + box-shadow: 0 1px 4px rgba(16, 20, 30, 0.08); + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease; + z-index: 6; +} + +.v2-msg:hover .v2-msg__actions, +.v2-msg__actions:focus-within { + opacity: 1; + pointer-events: auto; +} + +.v2-root button.v2-msg__action { + appearance: none; + border: none; + background: transparent; + color: var(--v2-text-secondary); + width: 26px; + height: 26px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.v2-root button.v2-msg__action:hover { + background: var(--v2-surface-hover); + color: var(--v2-text-primary); +} + +.v2-msg__action-wrap { position: relative; display: inline-flex; } + +.v2-msg__actions .v2-msg__reaction-picker { + position: absolute; + top: calc(100% + 6px); + bottom: auto; + right: 0; + left: auto; +} + +.v2-msg__body { + flex: 1; + min-width: 0; +} + +.v2-msg__head { + display: flex; + align-items: center; + gap: 7px; + height: 22px; + margin-bottom: 0; +} + +.v2-msg__author { + font-size: 13px; + font-weight: 700; + color: var(--v2-text-primary); +} + +.v2-msg__author-btn { + appearance: none; + background: transparent; + border: none; + padding: 0; + font-size: 13px; + font-weight: 700; + color: var(--v2-text-primary); + cursor: pointer; + border-radius: 4px; + transition: color 80ms ease; +} + +.v2-msg__author-btn:hover { + background: var(--v2-surface-hover); +} + +.v2-msg__author-kind { + display: inline-block; + height: 16px; + padding: 0 5px; + border-radius: 3px; + background: var(--v2-surface-hover); + color: var(--v2-accent-text); + font: 700 11px/16px var(--v2-font-mono); + letter-spacing: 0.02em; +} + +.v2-msg__avatar-btn { + appearance: none; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + border-radius: 50%; + display: inline-grid; + place-items: center; + width: fit-content; + height: fit-content; + line-height: 0; + transition: box-shadow 100ms ease; +} + +.v2-msg__avatar-btn:hover { + background: var(--v2-surface-hover); +} + +.v2-msg__lead-badge { + background: transparent; + color: var(--v2-text-tertiary); + font-family: var(--v2-font-mono); + height: 18px; + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.v2-msg__time { + font-size: 11px; + color: var(--v2-text-tertiary); +} + +.v2-msg__content { + /* D215 .mbody: 14/22 in ink-2, 3px under the head. */ + font-size: 14px; + line-height: 22px; + color: var(--v2-ink-2); + margin-top: 3px; + word-break: break-word; + /* NO measure cap — the THIRD ruling on this (2026-08-24 full-width Slack + model; #1367's craft-audit 75ch reverted 2026-09-01 by explicit request: + "I still don't like the char limit, looks really bad on desktop"). + Typographic best practice loses to the product decision here; the + invariants test now bans ch-unit max-widths on message content so an + audit can't re-litigate this silently. */ +} + +.v2-msg__content > *:first-child { margin-top: 0; } + +.v2-msg__content > *:last-child { margin-bottom: 0; } + +.v2-msg__content p { + margin: 0 0 8px; + white-space: pre-wrap; +} + +.v2-msg__content a { + color: var(--v2-accent-text); + text-decoration: underline; +} + +.v2-msg__mention { + background: color-mix(in srgb, var(--v2-accent) 10%, transparent); + color: var(--v2-accent-text); + padding: 0 4px; + border-radius: var(--v2-r-6); + font-weight: 500; + white-space: nowrap; +} + +/* A message that mentions you gets no row highlight: the tint lives on the + @handle chip only (D224). The class stays as a hook for tests and selectors. */ +.v2-msg--mention { background: none; } + +.v2-msg__content ul, +.v2-msg__content ol { + margin: 0 0 8px; + padding-left: 18px; +} + +.v2-msg__content li { + margin: 2px 0; +} + +/* D215 .cd: a mono chip on the tint with a soft inset ring. */ +.v2-msg__content code { + font: 500 12.5px/1 var(--v2-font-mono); + color: var(--v2-text-primary); + background: var(--v2-surface-tint); + padding: 2px 5px; + border-radius: 6px; + box-shadow: inset 0 0 0 1px var(--v2-border-soft); +} + +.v2-msg__content pre { + margin: 6px 0; + padding: 10px 12px; + background: var(--v2-surface-tint); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + overflow-x: auto; + font-size: 12.5px; + line-height: 1.5; +} + +.v2-msg__content pre code { + background: transparent; + padding: 0; + font-size: inherit; +} + +.v2-msg__content blockquote { + margin: 6px 0; + padding: 2px 0 2px 10px; + border-left: 3px solid var(--v2-border); + color: var(--v2-text-secondary); + font-style: italic; +} + +.v2-msg__content h1, +.v2-msg__content h2, +.v2-msg__content h3 { + margin: 8px 0 4px; + font-weight: 700; +} + +.v2-msg__content h1 { font-size: 15px; } + +.v2-msg__content h2 { font-size: 14.5px; } + +.v2-msg__content h3 { font-size: 14px; } + +.v2-msg__content hr { + border: none; + border-top: 1px solid var(--v2-border-soft); + margin: 8px 0; +} + +.v2-msg__content strong { font-weight: 700; } + +.v2-msg__content em { font-style: italic; } + +.v2-msg__reactions { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.v2-msg__reaction { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 9px; + border-radius: var(--v2-radius-sm); + background: var(--v2-surface); + font-size: 12px; + color: var(--v2-text-secondary); + border: 1px solid transparent; + transition: background 80ms ease, border-color 80ms ease; +} + +.v2-msg__reaction:hover { + background: var(--v2-surface-hover); +} + +/* Reactions row under message body — fixture-rendered (read-only). + Real reactions backend ships post-YC; this just renders [[reactions:...]] + tokens populated by the demo seed. */ +.v2-msg__reactions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.v2-msg__reaction { + display: inline-flex; + align-items: baseline; /* see button rule below -- ink vs box */ + gap: 4px; + height: 24px; + padding: 0 8px; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 999px; + font-size: 12px; + line-height: 1; + color: #4b5563; + font-variant-emoji: emoji; +} + +.v2-msg__reaction:hover { + background: var(--v2-surface-hover); +} + +.v2-msg__reaction-emoji { + font-size: 13px; + /* Chip inner height (24px - 2x1px border). With baseline alignment on the + * chip, this tall line box is the ONLY thing centering the glyph pair + * vertically -- shrink it back to 1 and the pair top-hugs the chip. */ + line-height: 22px; +} + +.v2-msg__reaction-count { + font-weight: 600; + font-size: 11px; +} + +/* Transient reason-a-reaction-failed line (bad emoji / not a member / rate + * limited). Auto-clears after ~4s from the component. */ +.v2-msg__reaction-error { + font-size: 11px; + color: var(--v2-danger, #c0392b); + align-self: center; +} + +/* Sprint B5 — interactive reaction chips + picker. Mine = an ink-bordered + * selection; not-mine = subtle gray (existing rule above). Same + * specificity workaround as other v2 buttons. */ +.v2-root button.v2-msg__reaction { + cursor: pointer; + background: #f3f4f6; + border: 1px solid #e5e7eb; + color: #4b5563; + height: 24px; + padding: 0 8px; + border-radius: 999px; + display: inline-flex; + /* Baseline, not center: box-centering aligns the spans' LAYOUT boxes, + * but Apple Color Emoji ink extends below the baseline while digit ink + * does not, so on desktop the count visibly rides high (reported + * 2026-08-05, HQ screenshot; reproduced 3x-zoomed in Chromium). Digits + * hanging on the emoji's baseline is how "\1F44D 2" renders in plain + * inline text -- the reference that looks right on every platform. The + * emoji span's 22px line-height (chip inner height, below) is what + * vertically centers the shared line inside the fixed-height chip. */ + align-items: baseline; + gap: 4px; + font-size: 12px; + line-height: 1; +} + +.v2-root button.v2-msg__reaction--mine { + background: var(--v2-surface); + border-color: var(--v2-ink); + color: var(--v2-ink); +} + +.v2-root button.v2-msg__reaction:hover:not(:disabled) { + background: var(--v2-surface-hover); +} + +.v2-root button.v2-msg__reaction:disabled { + cursor: default; + opacity: 0.8; +} + +/* The standalone "+" trigger, its hover wrap, and the --bare collapsed row + * were retired 2026-08-23: the reaction trigger lives in the + * .v2-msg__actions hover cluster (placement ruling), and a reactions + * row with no chips simply does not render — the phantom-band problem the + * --bare machinery solved cannot occur at all now. The picker below is + * shared by the cluster (which overrides its anchor to open downward). */ +.v2-msg__reaction-picker { + left: auto; + right: 0; +} + +.v2-msg__reaction-picker { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + display: flex; + gap: 2px; + padding: 4px; + background: var(--v2-surface, #fff); + border: 1px solid var(--v2-border, #e5e7eb); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + z-index: 10; +} + +.v2-root button.v2-msg__reaction-picker-item { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: 6px; + font-size: 16px; + cursor: pointer; + transition: background 0.1s ease; +} + +.v2-root button.v2-msg__reaction-picker-item:hover { + background: #f3f4f6; +} + +.v2-root button.v2-msg__reaction-picker-item--mine { + background: var(--v2-surface); + border: 1px solid var(--v2-ink); +} + +@media (hover: none), (pointer: coarse) { + /* No hover on touch — but always-on clusters over every message are + chrome over content (2026-08-23). A tap on the message toggles + .v2-msg--reveal (V2MessageRow.onBubbleTap, gated to hoverless + devices); only then does the cluster appear, at the 44px floor (craft + baseline rule 9). Absolute positioning preserved — a static third + child would break the avatar|body grid. */ + .v2-msg--reveal .v2-msg__actions, + .v2-msg__actions:focus-within { + opacity: 1; + pointer-events: auto; + } + /* Exception to the frame: the drawn 28px key keeps a 44px hit area. The + old 44px key size here was dead (the .v2-thread key size outranked it). + .v2-root on the gap: the strip's own rule sits later in this file. */ + .v2-root .v2-thread .v2-msg__strip { gap: 16px; } + .v2-root .v2-thread button.v2-msg__action { position: relative; } + .v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; } +} + +.v2-msg__quote { + display: flex; + gap: 6px; + margin: 2px 0 4px; + padding: 4px 10px; + border-left: 3px solid var(--v2-border); + font-size: 12.5px; + color: var(--v2-text-secondary); + overflow: hidden; +} + +.v2-msg__quote-author { font-weight: 600; flex-shrink: 0; } + +.v2-msg__quote-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* The card sits IN the message content column, not flush with the row edge: + 38px avatar column + 12px gap, in lockstep with the .v2-msg grid. Without + this inset the card starts 50px left of every bubble's text and reads as + misaligned (measured 372 vs 422, 2026-08-13). */ +.v2-msg.v2-msg--card { + padding-left: 50px; +} + +@media (max-width: 760px) { + .v2-msg.v2-msg--card:has(.v2-gig) { padding-left: 0; } +} + +@media (max-width: 640px) { + /* Touch has no hover: the action cluster appears on TAP-REVEAL + (.v2-msg--reveal, set by the bubble's tap handler) with 44px targets — + never by default; always-on clusters over every message are chrome + over content. The old inline head buttons this block used to force + visible were retired 2026-08-23. */ + .v2-msg__head { + height: auto; + flex-wrap: wrap; + } + .v2-msg--reveal .v2-msg__actions { + opacity: 1; + pointer-events: auto; + } +} + +.v2-root:lang(zh) .v2-msg__content, +.v2-root:lang(zh) .v2-msg__content pre { + line-height: 1.6; +} + +.v2-msg__ruled { + color: var(--v2-text-muted); + font: 500 11px/16px var(--v2-font-mono); +} + +.v2-thread .v2-msg { + /* D215 (TASK-734): 36px faces at radius 9, 12px to the text. */ + grid-template-columns: 36px minmax(0, 1fr); + gap: 12px; + padding: 9px 0; +} + +.v2-thread .v2-msg--grouped { padding: 2px 0; } + +.v2-thread .v2-msg__avatar-ghost { width: 36px; } + +.v2-thread .v2-msg__head { height: 20px; gap: 8px; } + +.v2-thread .v2-msg__author, +.v2-thread .v2-msg__author-btn { font-size: 14px; font-weight: 600; } + +/* The runtime and the time read as one mono line: "claude · 1:16 AM". */ +.v2-thread .v2-msg__time { font: 500 11.5px/16px var(--v2-font-mono); color: var(--v2-text-muted); } + +.v2-thread .v2-msg__avatar-btn { border-radius: var(--v2-r-9, 9px); } + +.v2-message-row--decision { padding: 8px 0; } + +@media (max-width: 760px) { + /* D215 390: 30px faces, 10px to the text; name, meta and body keep the + desktop 14 / 11.5 / 14-on-22 (the 15px phone type predates the frame). */ + .v2-thread .v2-msg { grid-template-columns: 30px minmax(0, 1fr); gap: 10px; } + .v2-thread .v2-msg__avatar-ghost { width: 30px; } + .v2-thread .v2-msg__avatar-btn .v2-avatar { width: 30px; height: 30px; } + /* not drawn in the frame yet: kept as it was */ + .v2-thread .v2-msg__ruled { font-size: 12px; } +} + +/* Attachments */ +.v2-msg__thumbs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.v2-root button.v2-msg__thumb { + display: block; + width: 156px; + height: 104px; + padding: 0; + overflow: hidden; + border: 1px solid var(--v2-border-soft); + border-radius: 4px; + background: var(--v2-surface-tint); + position: relative; +} + +.v2-root button.v2-msg__thumb:hover { border-color: var(--v2-border-strong); } + +.v2-msg__thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.v2-msg__thumb-name { + position: absolute; + left: 6px; + bottom: 6px; + max-width: calc(100% - 12px); + padding: 1px 5px; + border-radius: 3px; + background: var(--v2-surface); + color: var(--v2-text-secondary); + font: 500 11px/14px var(--v2-font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-msg__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + +.v2-root button.v2-msg__chip, +.v2-msg__chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-height: 30px; + padding: 5px 8px; + border: 1px solid var(--v2-border); + border-radius: 4px; + background: var(--v2-surface); + color: var(--v2-text-primary); + font: 400 12.5px/18px var(--v2-font); + text-align: left; +} + +.v2-root button.v2-msg__chip:hover { background: var(--v2-surface-hover); } + +.v2-msg__chip-ext { + flex: 0 0 auto; + padding: 1px 5px; + border-radius: 3px; + background: var(--v2-surface-tint); + color: var(--v2-text-secondary); + font: 500 11px/14px var(--v2-font-mono); + text-transform: uppercase; +} + +.v2-msg__chip-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-msg__chip-size { + flex: 0 0 auto; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); +} + +/* Long code collapses after six lines; expands in place. */ +.v2-msg__collapse { position: relative; } + +.v2-msg__collapse--closed pre { max-height: calc(6 * 1.5 * 12.5px + 20px); overflow: hidden; } + +/* Long agent prose folds after six lines (6 × 22px). The fade masks the prose itself, so a hovered row keeps its own surface. */ +.v2-msg__prose { position: relative; } + +.v2-msg__prose--closed { + max-height: 132px; + overflow: hidden; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 44px), transparent); +} + +.v2-root button.v2-msg__showmore { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + margin: 6px 0 0; + padding: 0 8px 0 6px; + border: 0; + border-radius: 7px; + background: var(--v2-surface); + color: var(--v2-text-secondary); + box-shadow: inset 0 0 0 1px var(--v2-border); + font: 500 12px/1 var(--v2-font); + cursor: pointer; +} + +.v2-root button.v2-msg__showmore:hover { background: var(--v2-surface-hover); } + +.v2-root button.v2-msg__showmore:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); } + +.v2-msg__showmore svg { width: 14px; height: 14px; flex: none; transition: transform 160ms; } + +.v2-msg__showmore[aria-expanded='true'] svg { transform: rotate(180deg); } + +.v2-root button.v2-msg__more { + display: inline-block; + margin: 2px 0 6px; + padding: 0; + border: 0; + background: transparent; + color: var(--v2-accent-text); + font: 500 12.5px/18px var(--v2-font); +} + +.v2-root button.v2-msg__more:hover { text-decoration: underline; } + +@media (max-width: 760px) { + .v2-root button.v2-msg__thumb { width: calc(50% - 3px); height: 84px; } +} + +/* Hover band and the expanded band share the panel colour, not the tint. */ +.v2-thread .v2-msg:hover, +.v2-thread .v2-msg:focus-within { background: var(--v2-bg-subtle); } + +.v2-thread .v2-msg { border-radius: 4px; } + +.v2-thread .v2-msg--landed { background: var(--v2-accent-soft, #e8ecfb); transition: background 0.6s ease; } + +/* Runtime label before the time on agent rows. D215 (TASK-734): plain mono, + no box, joined to the time by a middle dot. */ +.v2-msg__tag { + display: inline-block; + color: var(--v2-text-muted); + font: 500 11.5px/16px var(--v2-font-mono); + vertical-align: baseline; +} + +.v2-msg__tag + .v2-msg__time { margin-left: -4px; } + +.v2-msg__tag + .v2-msg__time::before { content: '· '; } + +/* Grouped rows: the time shows in the gutter on hover. */ +.v2-msg__gutter-time { + position: absolute; + left: 0; + top: 4px; + width: 36px; + color: var(--v2-text-placeholder); + font: 400 11px/16px var(--v2-font-mono); + text-align: center; + visibility: hidden; +} + +.v2-thread .v2-msg--grouped:hover .v2-msg__gutter-time { visibility: visible; } + +/* Quote: 2px rule, two lines, jumps to the source. */ +.v2-thread .v2-msg__quote { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + margin: 2px 0 6px; + padding: 0 0 0 10px; + border-left: 2px solid var(--v2-border); + color: var(--v2-text-secondary); + font: 400 13px/17px var(--v2-font); + cursor: pointer; + max-width: 560px; +} + +.v2-thread .v2-msg__quote:hover { border-left-color: var(--v2-border-strong); } + +.v2-thread .v2-msg__quote:focus-visible { outline: none; box-shadow: inset 2px 0 0 var(--v2-accent); } + +.v2-thread .v2-msg__quote-author { color: var(--v2-text-primary); font-weight: 600; } + +.v2-thread .v2-msg__quote-text { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Reactions: 20px chips, mine in the accent wash. */ +.v2-thread .v2-msg__reactions { gap: 6px; margin-top: 6px; } + +.v2-root .v2-thread button.v2-msg__reaction, +.v2-thread .v2-msg__reaction { + height: 20px; + padding: 0 7px; + border: 1px solid var(--v2-border-soft); + border-radius: 4px; + background: var(--v2-surface); + color: var(--v2-text-secondary); + font: 500 11px/18px var(--v2-font-mono); +} + +.v2-thread .v2-msg__reaction-emoji { font-size: 12px; line-height: 18px; } + +.v2-thread .v2-msg__reaction-count { font: 500 11px/18px var(--v2-font-mono); } + +.v2-root .v2-thread button.v2-msg__reaction--mine { + border-color: #d4dbf7; + background: var(--v2-accent-soft, #e8ecfb); + color: var(--v2-accent-text); +} + +.v2-root .v2-thread button.v2-msg__reaction--more { color: var(--v2-accent-text); } + +/* D215 .acts: a pop card at the row's top right, 2px in, r9, four 28px + ghost keys (Add reaction, Reply, Thread, More) with 17px glyphs. */ +.v2-thread .v2-msg__strip { + top: 2px; + right: 0; + gap: 2px; + padding: 2px; + border: 0; + border-radius: 9px; + background: var(--v2-surface); + box-shadow: var(--v2-shadow-pop); +} + +.v2-root .v2-thread button.v2-msg__action { width: 28px; height: 28px; border-radius: 8px; color: var(--v2-text-secondary); } + +.v2-root .v2-thread button.v2-msg__action:hover { background: var(--v2-surface-tint); color: var(--v2-text-primary); } + +.v2-thread .v2-msg__action svg { width: 17px; height: 17px; } + +.v2-msg__more-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 7; + display: flex; + min-width: 140px; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 1px solid var(--v2-border); + border-radius: 6px; + background: var(--v2-surface); +} + +.v2-root .v2-msg__more-menu > button { + display: block; + width: 100%; + padding: 6px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-primary); + font: 400 13px/18px var(--v2-font); + text-align: left; +} + +.v2-root .v2-msg__more-menu > button:hover { background: var(--v2-surface-hover); } + +/* The reveal is gated on capability in the row — `matchMedia('(hover: none)')`, + with no width term and no `(pointer: coarse)` — so its placement matches that + query EXACTLY (sprint-review 64468 + 64485). A comma'd `(pointer: coarse)` + would style stylus/hybrid machines where `isHoverless()` is false and the + reveal never fires: a dead rule. This block sits AFTER the `.v2-thread + .v2-msg__strip` rules it overrides (11229) for the same reason the double + pod-header meta needed source order, not specificity. */ +@media (hover: none) { + /* Long-press reveals the strip inline under the body. */ + .v2-thread .v2-msg { -webkit-touch-callout: none; } + /* The row is a 28px | 1fr grid and the strip precedes the body in DOM: left + to auto-place, a static strip takes the body's cell and the body drops + into the avatar column (one word per line — 390 walk). Pin it. */ + .v2-thread .v2-msg--reveal { background: var(--v2-bg-subtle); row-gap: 6px; } + .v2-thread .v2-msg--reveal .v2-msg__strip { + position: static; + grid-column: 2; + grid-row: 2; + justify-self: start; + display: inline-flex; + margin: 0; + opacity: 1; + pointer-events: auto; + } +} + +@media (max-width: 760px) { + /* Sizing is a width question, not a capability one. Exception to the + frame: the keys stay at the drawn 28px and each carries a 44px hit area + (8px each side); the 16px gap lets neighbouring hit areas meet without + overlapping. */ + .v2-thread .v2-msg__strip { gap: 16px; } + .v2-root .v2-thread button.v2-msg__action { position: relative; width: 28px; height: 28px; } + .v2-root .v2-thread button.v2-msg__action::after { content: ''; position: absolute; inset: -8px; } + .v2-thread .v2-msg__gutter-time { display: none; } +} diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css new file mode 100644 index 000000000..e35df35c5 --- /dev/null +++ b/frontend/src/v2/chat/chat-pane.css @@ -0,0 +1,775 @@ +/* Pod chat · pane, stream, thread, presence and header (S3b, D223 lift of + the d215 chat frame). + + Source: docs/design/artboards/d215/index.html, data-surface="chat" at 1440 + and 390, product CSS `.ph .stream .thr .rmsg .tfoot .tct .presence .dot`, + `.mtop`, `.app.m .stream/.thr`. Token map as in chat-message.css. + Imported by V2App.tsx after chat-message.css. */ + +.v2-chat { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; +} + +.v2-chat__header { + min-height: 78px; + padding: 14px 24px; + border-bottom: 1px solid var(--v2-border-soft); + background: var(--v2-surface); + display: flex; + flex-direction: column; + justify-content: center; + gap: 7px; +} + +.v2-chat__header-row { + display: flex; + align-items: center; + gap: 10px; +} + +.v2-chat__messages { + flex: 1; + overflow-y: auto; + /* The transcript never scrolls sideways — wide content (code, tables) + manages its own overflow-x inside its message. Without this, any + mis-sized row makes the whole pane horizontally swipeable (the + thread-block did exactly that; mobile report 2026-08-24). */ + overflow-x: hidden; + /* D215 .stream: 10 28 6; phone 8 12 4 below. */ + padding: 10px 28px 6px; + display: flex; + flex-direction: column; + gap: 0; +} + +/* Only the managed history operation disables native anchoring. Leaving the + default on at rest keeps late image loads above a reader's viewport stable; + V2Thread measures and compensates the history row itself. */ +.v2-chat__messages[data-history-anchor="active"] { + overflow-anchor: none; +} + +/* The transcript is the chat viewport's positioning context. History + recovery belongs to this viewport rather than the top of the scrollable + message list, otherwise a failure after a deep landing is stranded above + the reader's current scroll position. */ +.v2-thread__transcript { + position: relative; + display: flex; + flex: 1; + min-height: 0; +} + +.v2-thread__transcript > .v2-chat__messages { + min-height: 0; +} + +/* Conversation width — FIFTH revision of craft rule 2, ruled + 2026-08-23: FULL-WIDTH, the Slack/Discord model. The measure cap died + with its last corner: v1 centered messages alone (island), v2/v4 + left-anchored the cap (dead space on the right once the inspector + closes), v3 centered the whole column (rejected outright). With a line + cap, leftover space must pool somewhere — so the cap goes. Rows and + text span the pane and wrap at its edge; lines run long on wide + windows, the familiar chat tradeoff. What survives every revision: + one shared left edge for every conversation row, and margin-inline + stays an EXPLICIT 0 so a stray auto cannot re-center a subset. */ +.v2-chat__messages > * { + width: 100%; + margin-inline: 0; +} + +/* Agent presence (D215 .presence): a chip, not a row. It keeps its slot + between the scroller and the composer (moving it into the scroller would + touch history anchoring), so its left edge adds the stream's 28px gutter to + the frame's 48px text column. The pulsing cobalt dot carries "alive"; + reduced motion keeps the dot and drops the pulse. */ +.v2-chat__typing { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 7px; + height: 24px; + padding: 0 9px 0 7px; + margin: 0 0 6px 76px; + border-radius: 7px; + background: var(--v2-bg-subtle); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + color: var(--v2-text-secondary); + font: 500 11.5px/1 var(--v2-font-mono); + flex-shrink: 0; +} + +.v2-chat__typing-label { + flex-shrink: 0; +} + +.v2-chat__typing-dot { + position: relative; + display: inline-block; + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--v2-accent); +} + +.v2-chat__typing-dot::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--v2-accent); + opacity: 0; + animation: v2-presence-pulse var(--motion-pulse) var(--motion-ease-state) infinite; +} + +@keyframes v2-presence-pulse { + 0% { transform: scale(0.6); opacity: 0.6; } + 100% { transform: scale(1.4); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .v2-chat__typing-dot::after { animation: none; } +} + +@media (max-width: 760px) { + .v2-chat__typing { margin-left: 52px; } +} + +@media (max-width: 760px) { + .v2-thread__title-line .v2-hint--open { display: contents; } + .v2-thread__title-line .v2-hint__content { width: 100%; flex-basis: 100%; order: 1; } +} + +@media (max-width: 760px) { + .v2-chat__messages { + padding: 8px 12px 4px; + } + .v2-chat__header-row { + flex-wrap: wrap; + } +} + +/* Card + rail travel as ONE column row, indented to the message TEXT + column like an attachment (2026-08-23): 38px avatar column + 12px + gap = 50px, so the thread visibly belongs to the message above it + rather than starting a new row at the avatar edge. The bottom margin is + the rail's terminator: without it the rail's last pixel abuts the next + outer message and reads as connected to it. */ +.v2-thread-block { + margin-left: 50px; + /* The column's `> *` rule sets width: 100%; with the 50px indent that + put every thread 50px past the pane's right edge — the pane became + horizontally swipeable, worst on phones (2026-08-24; measured + scrollWidth 350 vs clientWidth 326 at 390px). Indent + width must sum + to 100%. */ + width: calc(100% - 50px); + margin-bottom: 14px; +} + +.v2-thread-card { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 0 0; /* brief: no gap > 8px between root and card */ +} + +.v2-root button.v2-thread-card__main { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; /* brief: 44px min hit height */ + padding: 6px 12px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: transparent; + box-shadow: none; + cursor: pointer; + font-size: 13px; + color: #4b5563; + transition: border-color 300ms ease; +} + +.v2-root button.v2-thread-card__main:hover { + border-color: #d7dce7; +} + +.v2-thread-card__count { + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* The card's ONLY accent, and only when something in the thread addresses me. + Ambient activity never colours it — that is the whole point of 3/4's + ambient/addressed split being visible in the UI rather than only in the + wake path. */ +.v2-thread-card--addressed .v2-thread-card__count { + color: var(--v2-accent); +} + +.v2-thread-card__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--v2-accent); +} + +.v2-thread-card__faces { + display: inline-flex; +} + +.v2-thread-card__faces > * + * { + margin-left: -6px; /* brief: overlapping −6px */ +} + +.v2-thread-card__time { + color: #7b8494; +} + +.v2-thread-replies { + position: relative; + transition: height 300ms ease; +} + +@media (max-width: 640px) { + .v2-thread-card { + flex-wrap: wrap; + } + /* Follow is not drawn in the frame, so it keeps the 44px touch floor it + had. Reply in thread and Collapse are drawn at 32px on phone and take + their 44px hit area from a ring (chat-pane.css, 760px block). */ + .v2-root .v2-thread-replies__foot button.v2-thread-replies__follow { + min-height: 44px; + } + .v2-root button.v2-thread-card__main { + flex: 1 1 100%; + flex-wrap: wrap; /* brief: card goes to two lines at 390 */ + } +} + +@media (prefers-reduced-motion: reduce) { + .v2-root button.v2-thread-card__main, + .v2-thread-replies { + transition: none; + } +} + +.v2-root:lang(zh) .v2-thread__title h1 { + letter-spacing: 0; +} + +.v2-thread__header { + flex: 0 0 auto; + box-sizing: border-box; + /* D215 (TASK-734): the pane head is 64px, title left at 24px. */ + min-height: 64px; + padding: 0 20px 0 24px; + border-bottom: 1px solid var(--v2-border-soft); + background: var(--v2-surface); +} + +.v2-thread__header-row { + display: flex; + min-height: 64px; + align-items: center; + gap: 10px; + min-width: 0; +} + +.v2-thread__title { + min-width: 0; + flex: 1; +} + +.v2-thread__title-line { + display: flex; + align-items: baseline; + min-width: 0; + gap: 8px; +} + +.v2-thread__title h1 { + margin: 0; + overflow: hidden; + color: var(--v2-text-primary); + /* D215 (TASK-734) brings the display face back to the pod title, at 18/700 + with the artboard's tracking. */ + font: 700 18px/24px var(--v2-font-display); + letter-spacing: -0.025em; + text-overflow: ellipsis; + white-space: nowrap; + /* The description gives way before the name truncates (walk-1 §3). */ + flex: 0 1 auto; + min-width: 0; + max-width: 100%; +} + +/* Look from the D223 primitives (primitives.css): v2p-b v2p-b-g v2p-ib. */ +.v2-root button.v2-thread__inspector-toggle { flex: 0 0 32px; } + +/* Look from the D223 primitives (primitives.css): v2p-b v2p-b-s v2p-b-sm. */ +.v2-root button.v2-pod-header__invite { flex: 0 0 auto; } + +/* On a phone the label would take the pod name's width, so the button keeps + only its glyph, square like the inspector toggle. The label stays in the + accessibility tree as the button's name. */ +@media (max-width: 760px) { + /* The phone head's keys are ghosts (D215 phone frame), Invite included. */ + .v2-root button.v2-pod-header__invite { + width: 32px; + height: 32px; + flex: 0 0 32px; + justify-content: center; + padding: 0; + border-color: transparent; + background: transparent; + box-shadow: none; + color: var(--v2-text-secondary); + } + /* box-shadow: none above outranks the primitive's focus ring, so the ring + is restated for keyboard focus. */ + .v2-root button.v2-pod-header__invite:focus-visible { box-shadow: 0 0 0 3px var(--v2-accent-soft), 0 0 0 1px var(--v2-accent); } + .v2-root button.v2-pod-header__invite span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } +} + +.v2-thread .v2-avatar { + width: 36px; + height: 36px; + border: 0; + border-radius: var(--v2-r-9, 9px); + box-shadow: 0 0 0 1px var(--v2-av-ring, rgba(16, 24, 40, 0.08)); +} + +@media (max-width: 760px) { + .v2-thread__header { + min-height: 50px; + padding: 0 12px; + } + .v2-thread__header-row { align-items: center; gap: 8px; } + .v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; } + /* The name takes the free width with a zero basis and truncates, so the + "i" hint keeps its place on the line instead of wrapping under it and + growing the header (TASK-647 item 3). */ + .v2-thread__title h1 { font: 600 15px/20px var(--v2-font); flex: 1 1 0; min-width: 0; white-space: nowrap; text-overflow: ellipsis; } +} + +/* Pod header (D215, TASK-734): 64px, display 18/700 title, description + inline, then the board chip and the member faces on the right. */ +.v2-pod-header .v2-thread__header-row { + min-height: 64px; +} + +.v2-pod-header__meta { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); + white-space: nowrap; +} + +/* The compact meta is the phone's; both carry the base class, so the + compact rule must out-rank the base `display: flex` above it. */ +.v2-pod-header__meta.v2-pod-header__meta--compact { display: none; } + +/* Three faces, overlapped, then "+N" for the rest of the pod. */ +.v2-pod-header__stack { display: inline-flex; align-items: center; } + +.v2-thread .v2-pod-header__stack .v2-avatar { + width: 24px; + height: 24px; + border-radius: var(--v2-r-6, 6px); + box-shadow: 0 0 0 2px var(--v2-surface); + font-size: 12px; +} + +.v2-thread .v2-pod-header__stack .v2-avatar + .v2-avatar { margin-left: -6px; } + +.v2-pod-header__more { color: var(--v2-text-muted); font: 500 11.5px/1 var(--v2-font-mono); } + +/* D215: the board is a chip, a board glyph and the count. */ +.v2-root button.v2-pod-header__board { + display: inline-flex; + height: 24px; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 0; + border-radius: 7px; + background: var(--v2-surface); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); + color: var(--v2-text-secondary); + font: 500 11px/1 var(--v2-font-mono); + cursor: pointer; +} + +.v2-root button.v2-pod-header__board:hover { background: var(--v2-surface-hover); color: var(--v2-text-primary); } + +.v2-root button.v2-pod-header__board:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); } + +/* Owner rename (TASK-419): an inline form in the title line. It wraps at phone + width; the input takes the spare room and the buttons keep their content width. */ +.v2-pod-header__rename { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.v2-root .v2-pod-header__rename input { + flex: 1 1 180px; + min-width: 0; + max-width: 100%; + padding: 6px 10px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + font: 600 15px/20px var(--v2-font); +} + +.v2-root .v2-pod-header__rename input:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } + +.v2-root .v2-pod-header__rename button { + flex: 0 0 auto; + min-height: 30px; + padding: 4px 10px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + font: 600 12px/16px var(--v2-font); + cursor: pointer; +} + +.v2-root .v2-pod-header__rename button[type="submit"] { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); } + +.v2-root .v2-pod-header__rename button:not([type="submit"]):hover:not(:disabled) { background: var(--v2-surface-hover); } + +.v2-root .v2-pod-header__rename button[type="submit"]:hover:not(:disabled) { background: var(--v2-ink-hover); color: var(--v2-on-ink); } + +.v2-root .v2-pod-header__rename button:disabled { opacity: .55; cursor: default; } + +.v2-root .v2-pod-header__rename button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } + +.v2-pod-header__rename-error { flex: 1 0 100%; color: var(--v2-danger); font: 400 12px/16px var(--v2-font); } + +/* D215: rename is a pencil on the title, out of sight until the title line is + hovered or the pencil has keyboard focus. Touch screens show it at rest. */ +.v2-root button.v2-pod-header__rename-open { opacity: 0; } + +.v2-thread__title-line:hover button.v2-pod-header__rename-open, +.v2-root button.v2-pod-header__rename-open:focus-visible { opacity: 1; } + +@media (hover: none) and (pointer: coarse) { + .v2-root button.v2-pod-header__rename-open { opacity: 1; } +} + +.v2-root button.v2-thread__back { display: none; } + +@media (max-width: 760px) { + /* D215 phone head: 52px, back, title, board chip, panel. */ + .v2-thread__header { min-height: 52px; padding: 0 10px 0 12px; } + .v2-pod-header .v2-thread__header-row { + box-sizing: border-box; + min-height: 52px; + padding: 10px 0; + /* 12px, not the artboard's 8: the 44px tap areas below reach 6px past + each 32px key, and 12px keeps neighbours from overlapping. */ + gap: 12px; + align-items: flex-start; + } + .v2-root button.v2-thread__back, + .v2-root button.v2-pod-header__invite, + .v2-root button.v2-thread__inspector-toggle { position: relative; z-index: 1; } + .v2-root button.v2-thread__back::after, + .v2-root button.v2-pod-header__invite::after, + .v2-root button.v2-thread__inspector-toggle::after { + content: ''; + position: absolute; + /* From the padding box, inside each key's 1px border: 30 + 2 x 7 = 44. */ + inset: -7px; + } + /* The keys are 32px; an open description grows the title block downward + while the name stays centred on the keys. */ + .v2-pod-header .v2-thread__title { display: flex; min-height: 32px; flex-direction: column; justify-content: center; } + .v2-thread__title h1 { font: 700 16px/20px var(--v2-font-display); letter-spacing: -0.02em; } + .v2-pod-header__meta { display: none; } + .v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; flex: 0 0 auto; white-space: nowrap; } + /* TASK-647 item 3: the board link keeps a 44px tap target without growing + the 28px meta row (the hit area is a pseudo-element), the name keeps room + for the "i" hint, and the compact meta sits closer to the link. */ + .v2-root button.v2-pod-header__board--compact { position: relative; } + .v2-root button.v2-pod-header__board--compact::before { + content: ''; + position: absolute; + /* 6px sideways, not 10: with the 12px header gap, the next key's 44px + area reaches 6px toward this one, so 6 + 6 meets without overlap. */ + inset: -14px -6px; + } + .v2-thread__title-line > h1 { max-width: calc(100% - 36px); } + .v2-pod-header__meta--compact { gap: 4px; } + /* Look from the primitives: v2p-b v2p-b-g v2p-ib. */ + .v2-root button.v2-thread__back { display: inline-flex; flex: 0 0 32px; } +} + +.v2-thread__empty-line { + display: block; + padding: 12px 0; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); +} + +/* History edge + jump pill */ +.v2-thread__edge { + display: flex; + justify-content: center; + padding: 6px 0 10px; +} + +.v2-thread__edge-line { + padding: 0; + border: 0; + background: transparent; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); +} + +.v2-root button.v2-thread__edge-line { + padding: 0; + border: 0; + background: transparent; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); +} + +.v2-root button.v2-thread__edge-line { cursor: pointer; } + +.v2-root button.v2-thread__edge-line:hover { color: var(--v2-text-primary); } + +.v2-thread__history-status { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 2px 0 10px; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); + text-align: center; +} + +.v2-thread__history-status--error { color: var(--v2-text-secondary); } + +.v2-thread__history-status--viewport { + position: absolute; + top: 8px; + left: 24px; + right: 24px; + z-index: 4; + min-height: 28px; + padding: 6px 10px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + background: var(--v2-surface); + pointer-events: none; +} + +.v2-thread__history-status--viewport .v2-thread__history-retry { + pointer-events: auto; +} + +.v2-root button.v2-thread__history-retry { + padding: 0; + border: 0; + background: transparent; + color: var(--v2-accent-text); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +/* The wrap is the scroller's last child and STICKS to its bottom edge: a + 0-height wrap with an absolute pill resolved `bottom: 12px` against a box + at the end of the transcript, ~1100px below the viewport when the pill + mounts a viewport up (ux-lead gate on #1579). Sticky needs real height. */ +.v2-thread__jump-wrap { + position: sticky; + bottom: 12px; + z-index: 3; + display: flex; + justify-content: center; + height: 28px; + pointer-events: none; +} + +.v2-root button.v2-thread__jump { + display: inline-flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 12px; + pointer-events: auto; + border: 1px solid #dde0e6; + border-radius: 14px; + background: var(--v2-surface); + color: var(--v2-text-primary); + font: 600 13px/26px var(--v2-font); + white-space: nowrap; +} + +.v2-root button.v2-thread__jump:hover { background: var(--v2-surface-hover); } + +.v2-thread__jump-count { color: var(--v2-accent-text); font: 700 11px/16px var(--v2-font-mono); } + +/* Thread summary chip: faces · N replies · last age. */ +.v2-thread .v2-thread-card { margin: 4px 0 0; gap: 10px; } + +.v2-root .v2-thread button.v2-msg__thread-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 8px 0 4px; + border: 1px solid var(--v2-border-soft); + border-radius: 4px; + background: var(--v2-surface); + color: var(--v2-accent-text); + font: 600 13px/18px var(--v2-font); +} + +.v2-root .v2-thread button.v2-msg__thread-chip:hover { background: var(--v2-surface-hover); border-color: var(--v2-border); } + +.v2-thread .v2-thread-card__faces .v2-avatar { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--v2-surface); font: 650 11px/1 var(--v2-font-mono); } + +.v2-thread .v2-thread-card__faces > * + * { margin-left: -4px; } + +.v2-thread .v2-thread-card__count { color: var(--v2-accent-text); font-weight: 600; } + +.v2-thread .v2-thread-card__time { color: var(--v2-text-muted); font: 400 11px/16px var(--v2-font-mono); } + +/* Expanded thread (D215 .thr, #3178 / TASK-745): the root stays a plain + transcript row and the replies sit under it in their own card at the text + column (36px avatar + 12px gap = 48px). The band that once wrapped root and + replies, and the rail beside the replies, are gone. */ +.v2-thread .v2-thread-block { margin: 0 0 10px; width: 100%; } + +.v2-thread .v2-thread-block__inner { margin-left: 48px; } + +.v2-thread .v2-thread-block .v2-thread-card { margin: 2px 0 0; } + +.v2-thread .v2-thread-replies { + display: grid; + gap: 8px; + margin: 2px 0 0; + padding: 10px 12px 8px; + border: 0; + border-radius: 10px; + background: var(--v2-bg-subtle); + box-shadow: inset 0 0 0 1px var(--v2-border-soft); +} + +/* D215 .thr .rmsg: 24px avatar, 10px gap, 18px head, 13px name, 13.5/20 body. */ +.v2-thread .v2-thread-replies .v2-msg { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 0; margin: 0; } +/* A UI card posted as a reply keeps its own 4px stack gap. uiCard.css now loads + before this sheet, so the reply rule above would otherwise win the tie. */ +.v2-thread .v2-thread-replies .v2-msg.v2-ui-card { gap: 4px; } + +.v2-thread .v2-thread-replies .v2-msg--grouped { padding: 0; } + +.v2-thread .v2-thread-replies .v2-msg__head { height: 18px; } + +.v2-thread .v2-thread-replies .v2-msg__author, +.v2-thread .v2-thread-replies .v2-msg__author-btn { font-size: 13px; } + +.v2-thread .v2-thread-replies .v2-msg__content { margin-top: 1px; font-size: 13.5px; line-height: 20px; } + +.v2-thread .v2-thread-replies .v2-msg:hover { background: transparent; } + +.v2-thread .v2-thread-replies .v2-msg .v2-avatar { width: 24px; height: 24px; border-radius: var(--v2-r-6, 6px); font-size: 12px; } + +/* D215 .tfoot: Reply in thread (secondary key), Collapse (ghost key, chevron + turned up), the count pushed right. Follow is not drawn; it keeps its look + and sits after the count. */ +.v2-thread-replies__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 0; +} + +.v2-root .v2-thread-replies__foot .v2p-b-g svg { transform: rotate(180deg); } + +.v2-thread-replies__count { + margin-left: auto; + color: var(--v2-text-muted); + font: 500 11.5px/1 var(--v2-font-mono); +} + +.v2-root button.v2-thread-replies__more { + padding: 0; + border: 0; + background: transparent; + color: var(--v2-accent-text); + font: 500 13px/18px var(--v2-font); + cursor: pointer; +} + +.v2-root button.v2-thread-replies__more { display: block; margin: 2px 0 4px; } + +.v2-root button.v2-thread-replies__follow { + margin-left: 8px; + padding: 0; + border: 0; + background: transparent; + color: var(--v2-text-muted); + font: 400 11px/16px var(--v2-font-mono); + cursor: pointer; +} + +.v2-root button.v2-thread-replies__follow--on { color: var(--v2-accent-text); } + +/* Transcript avatars are two-tone squares (ux-lead 64476 (1)): human tint + + #4f5c6e initials, agent cobalt + white initials; a photo still wins. */ +.v2-thread .v2-avatar--flat { border: 0; box-shadow: 0 0 0 1px var(--v2-av-ring, rgba(16, 24, 40, 0.08)); border-radius: var(--v2-r-9, 9px); font: 650 12px/1 var(--v2-font); letter-spacing: 0.02em; } + +.v2-thread .v2-avatar--flat-human { background: #f2f4f7; color: #4f5c6e; } + +.v2-thread .v2-avatar--flat-agent { background: var(--v2-accent); color: var(--v2-bg); } + +.v2-thread .v2-thread-replies .v2-msg .v2-avatar--flat { border-radius: var(--v2-r-6, 6px); font: 650 12px/1 var(--v2-font); } + +.v2-thread .v2-thread-card__faces .v2-avatar--flat { font: 650 11px/1 var(--v2-font-mono); } + +@media (max-width: 760px) { + .v2-root .v2-thread button.v2-msg__thread-chip { min-height: 32px; } + /* D215 .app.m .thr: 40px indent, tighter card, 32px foot keys. The drawn + key keeps 32px; an invisible ring takes the hit area to 44px. */ + .v2-thread .v2-thread-block__inner { margin-left: 40px; } + .v2-thread .v2-thread-replies { padding: 8px 10px 8px; } + .v2-thread-replies__foot { gap: 12px; } + .v2-root .v2-thread-replies__foot .v2p-b-sm { position: relative; height: 32px; } + /* From the padding box, inside the key's 1px border: 30 + 2 x 7 = 44; the 12px gap + takes 6px of reach from each side, so neighbouring rings meet without overlapping. */ + .v2-root .v2-thread-replies__foot .v2p-b-sm::after { content: ''; position: absolute; inset: -7px; } +} diff --git a/frontend/src/v2/chat/chat-states.css b/frontend/src/v2/chat/chat-states.css new file mode 100644 index 000000000..2d5fd25de --- /dev/null +++ b/frontend/src/v2/chat/chat-states.css @@ -0,0 +1,1037 @@ +/* Pod chat · states the d215 frame does not draw (S3d, D223 lift). + + Every rule here moved out of v2.css unchanged: empty and starter states, + catch-up, delivery hints, cards and other chat-only surfaces the frame has + not drawn yet. Redraw them here when a frame lands. Imported by V2App.tsx + after chat-inspector.css. */ + +/* Catch-up strip — the constant agents-TL;DR pinned above the transcript +/* Catch-up strip — the constant agents-TL;DR pinned above the transcript + (2026-09-01). One thin row; the expanded body is bounded so a long + summary scrolls inside the strip, never the pane. */ +.v2-catchup { + border-bottom: 1px solid var(--v2-border); + background: var(--v2-surface); + padding: 6px 20px; + font-size: 13px; +} + +.v2-catchup__row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.v2-root button.v2-catchup__toggle { + background: transparent; + border: none; + padding: 0; + font-size: 13px; + font-weight: 650; + color: var(--v2-text-secondary); + cursor: pointer; + white-space: nowrap; +} + +.v2-root button.v2-catchup__toggle:hover:not(:disabled) { color: var(--v2-text-primary); } + +.v2-root button.v2-catchup__toggle:disabled { cursor: default; } + +.v2-catchup__chevron { + display: inline-block; + margin-right: 4px; + transition: transform 0.08s ease; +} + +.v2-catchup__chevron--open { transform: rotate(90deg); } + +.v2-catchup__time { font-weight: 400; color: var(--v2-text-tertiary); } + +.v2-catchup__snippet { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--v2-text-tertiary); +} + +.v2-root button.v2-catchup__refresh { + background: transparent; + border: none; + padding: 0; + font-size: 12px; + color: var(--v2-accent); + cursor: pointer; + white-space: nowrap; +} + +.v2-root button.v2-catchup__refresh:hover:not(:disabled) { text-decoration: underline; } + +.v2-root button.v2-catchup__refresh:disabled { color: var(--v2-text-tertiary); cursor: default; } + +.v2-root button.v2-catchup__dismiss { + background: transparent; + border: none; + padding: 0 2px; + font-size: 14px; + line-height: 1; + color: var(--v2-text-tertiary); + cursor: pointer; +} + +.v2-root button.v2-catchup__dismiss:hover { color: var(--v2-text-primary); } + +.v2-catchup__body { + margin-top: 6px; + max-height: 220px; + overflow-y: auto; + white-space: pre-wrap; + color: var(--v2-text-secondary); + line-height: 1.5; +} + +/* Sender-only teaching cue shown once per pod/session when a message reaches + a pod with agents but addresses none of them. Aligns with message text, + stays quiet/secondary, and disappears naturally with transcript scroll. + Box geometry is NOT here: it belongs to the side-row slot rule below, which + this row shares with the no-agent row. */ +.v2-chat__delivery-hint { + color: var(--v2-text-tertiary); + font-size: 12px; + line-height: 1.4; +} + +.v2-chat__delivery-hint strong { + color: var(--v2-ink); + font-weight: 600; +} + +/* The transcript's two side-rows — the sender-only delivery cue above and the + no-agent row below — occupy ONE slot, and that slot IS the message text + column of the transcript they sit in. Both are siblings of `.v2-msg` inside + `.v2-chat__messages`, so the indent is the avatar track plus the gap of the + rule that governs those rows HERE, which is the thread override + `.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px }` + — 28 + 8 = 36, NOT the base `.v2-msg` grid (38 + 12 = 50), which that + override replaces (0,3,0 beats 0,1,0). The first cut of this rule derived + from the base grid and was 14px out; ux-lead's #1860 gate measured it at + 390, 720 and 1440 — 50px put both rows 14px right of `.v2-msg__content`, + 36px put them on it (injection on their fixture, right edge and scrollWidth + unchanged). Both placements of these rows share their message's container + (transcript level, or inside a `.v2-thread-block` for a thread root), so the + same relative indent holds in both. + + The width subtracts the same indent: `.v2-chat__messages > *` sets + width: 100%, so an indent without its complement overruns the pane by exactly + the indent. That was the #1846 defect — at 390 the full-width act ran 64→426 + and the body line was cut (ux-lead's shot, 2026-09-23) — and the same + class one indent over on `.v2-thread-block` (2026-08-24): indent + width must + sum to 100%. + + One rule for both rows, so they cannot drift apart again: this is the only + place either row's box is declared, and v2-layout-invariants.test.ts derives + the expected indent from `.v2-thread .v2-msg`'s own grid, so moving that + column fails the suite instead of silently misaligning these rows. */ +.v2-chat__delivery-hint, +.v2-chat__no-agents, +.v2-chat__starts { + width: calc(100% - 48px); + margin: -4px 0 8px 48px; +} + +/* The delivery hint above requires an agent to name (`agentsInPod > 0`), which + leaves out the pod where nothing can answer at all — a team pod with no agent + installed. This row sits in the same slot under the sent message, says why + the send went quiet, and carries the one step that changes it. Ink and tint + only: nothing here succeeded, and no accent or toast should say otherwise. */ +.v2-chat__no-agents { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + /* Box geometry lives in the shared side-row slot rule above, next to the + delivery cue it shares the slot with. */ +} + +.v2-chat__no-agents-kicker { + color: var(--v2-text-tertiary); + font-family: var(--v2-font-mono); + font-size: 11px; + font-weight: 500; + line-height: 16px; + text-transform: lowercase; +} + +.v2-chat__no-agents-body { + color: var(--v2-text-secondary); + font-size: 12px; + line-height: 1.4; +} + +.v2-root button.v2-chat__no-agents-action { + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + font-size: 12px; + font-weight: 600; + transition: background 80ms ease, border-color 80ms ease; +} + +.v2-root button.v2-chat__no-agents-action:hover { + background: var(--v2-surface-hover); +} + +/* Scout's starts (first session, direction B, ruled 2026-10-15): the welcome's + offers, one bordered button per start, in one column at one width so they + read as a set. A click sends the start as the person's own message. Box + geometry lives in the shared side-row slot rule above. */ +.v2-chat__starts { + display: grid; + grid-template-columns: minmax(0, max-content); + gap: 6px; +} + +.v2-root button.v2-chat__start { + min-height: 32px; + padding: 6px 12px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + font-size: 13px; + font-weight: 500; + line-height: 1.35; + text-align: left; + white-space: normal; + transition: background 80ms ease, border-color 80ms ease; +} + +.v2-root button.v2-chat__start:hover:not(:disabled) { + background: var(--v2-surface-hover); + border-color: var(--v2-border-strong); +} + +.v2-root button.v2-chat__start:disabled { + opacity: 0.6; + cursor: default; +} + +/* TASK-647 item 4: the run-hosted start is the screen's one filled action. + Keyed on the action in the component, not on its position. */ +.v2-root button.v2-chat__start.v2-chat__start--primary { + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); + font-weight: 600; +} + +.v2-root button.v2-chat__start.v2-chat__start--primary:hover:not(:disabled) { + background: var(--v2-ink-hover); + border-color: var(--v2-ink-hover); +} + +/* A line that belongs to the start above it: the hosted start's beta limits, + or why it could not start. Same slot and size as the delivery hint. */ +.v2-chat__start-note { + margin: -2px 0 2px; + color: var(--v2-text-tertiary); + font-size: 12px; + line-height: 1.4; +} + +.v2-chat__start-note--error { + color: var(--v2-danger); +} + +@media (max-width: 760px) { + /* The row's single act is a touch target on a phone: full width of the row, + 44 tall. This must override the MINIMUM, not a height — a `height` here + would lose to the base rule's own min-height and render 32 (TASK-140). */ + .v2-root button.v2-chat__no-agents-action { + width: 100%; + min-height: 44px; + } + /* Scout's starts are touch targets too: 44 tall on a phone, as drawn. */ + .v2-root button.v2-chat__start { + min-height: 44px; + } +} + +.v2-chat__error { + padding: 9px 12px; + border-radius: 8px; + background: rgba(239, 68, 68, 0.08); + color: var(--v2-danger); + font-size: 12px; + border: 1px solid rgba(239, 68, 68, 0.16); +} + +/* A 1:1 agent room must not make a successful POST look like an active + agent. The liveness row is deliberately adjacent to, but outside, the + composer: it remains visible while someone writes and never becomes part + of the scrollable transcript. */ +.v2-chat__agent-room-status { + margin: 8px 24px 0; + padding: 8px 10px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + background: var(--v2-bg-subtle); + color: var(--v2-text-secondary); + font-size: 12px; + line-height: 1.4; +} + +.v2-chat__agent-room-command { + display: block; + margin-top: 4px; + font-family: var(--v2-font-mono, ui-monospace, monospace); + overflow-wrap: anywhere; + user-select: all; +} + +.v2-chat__agent-room-status--wait { + border-color: var(--v2-border); + background: var(--v2-surface-hover); + color: var(--v2-text-secondary); + font-family: var(--v2-font-mono); + font-weight: 500; +} + +.v2-chat__agent-room-status-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--v2-success); +} + +.v2-chat__agent-room-status--timeout { + border-color: var(--v2-border-strong); + background: var(--v2-bg-subtle); + color: var(--v2-text-secondary); + font-weight: 500; +} + +.v2-chat__starter-prompts { + display: flex; + flex-wrap: wrap; + gap: 8px; + /* Part of the conversation column: siblings of the messages list share + its full pane width and left edge (rule 2 v5, full-width) — a second + edge here recreates the v1 island. max-width keeps the mobile-shrink + property the 390px pane needs. */ + width: 100%; + max-width: 100%; + margin-inline: 0; + padding: 10px 24px 0; + background: var(--v2-surface); + flex-shrink: 0; +} + +.v2-chat__new-pod { + align-self: center; + width: min(760px, 100%); + margin: auto; + padding: 24px; + border: 1px solid var(--v2-border-strong); + border-radius: var(--v2-radius-lg); + background: var(--v2-bg-subtle); +} + +.v2-chat__new-pod-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.v2-chat__new-pod-title { + color: var(--v2-text-primary); + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.v2-root button.v2-chat__new-pod-dismiss { + display: grid; + flex: 0 0 auto; + width: 28px; + height: 28px; + place-items: center; + border-radius: var(--v2-radius-sm); + color: var(--v2-text-tertiary); +} + +.v2-root button.v2-chat__new-pod-dismiss:hover { + background: var(--v2-surface-hover); + color: var(--v2-text-primary); +} + +.v2-chat__new-pod-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.v2-root button.v2-chat__new-pod-action, +.v2-chat__new-pod-action { + display: flex; + min-width: 0; + min-height: 88px; + flex-direction: column; + align-items: flex-start; + gap: 5px; + padding: 12px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + text-align: left; +} + +.v2-root button.v2-chat__new-pod-action { + transition: background 80ms ease, border-color 80ms ease; +} + +.v2-root button.v2-chat__new-pod-action:hover { + background: var(--v2-surface-hover); +} + +.v2-root button.v2-chat__new-pod-action--hosted { + background: var(--v2-ink); + color: var(--v2-on-ink); + font-weight: 600; +} + +.v2-root button.v2-chat__new-pod-action--hosted:hover { + background: var(--v2-ink-hover); +} + +.v2-root button.v2-chat__new-pod-action--hosted:disabled { + opacity: 0.7; +} + +.v2-chat__new-pod-action-title { + color: var(--v2-text-primary); + font-size: 13px; + font-weight: 700; +} + +.v2-chat__new-pod-status { + color: var(--v2-text-secondary); + font-size: 11px; + line-height: 1.4; +} + +.v2-chat__new-pod-action--invite .v2-invite-link-row { + width: 100%; + margin-top: auto; +} + +.v2-root button.v2-chat__new-pod-copy { + flex: 0 0 auto; + padding: 0 10px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + background: var(--v2-surface); + color: var(--v2-accent-text); + font-size: 11px; + font-weight: 700; +} + +.v2-chat__new-pod-action--invite > .v2-chat__new-pod-copy { + min-height: 32px; + margin-top: auto; +} + +@media (max-width: 760px) { + .v2-chat__new-pod-action--invite > .v2-chat__new-pod-copy { min-height: 44px; } +} + +.v2-root button.v2-chat__new-pod-copy:hover { + background: var(--v2-surface-hover); +} + +.v2-chat__new-pod-error { + display: flex; + align-items: center; + gap: 8px; + margin-top: auto; + color: var(--v2-danger); + font-size: 11px; +} + +.v2-root .v2-chat__new-pod-error button { + flex: 0 0 auto; + color: var(--v2-accent-text); + font-size: 11px; + font-weight: 700; +} + +.v2-root button.v2-chat__starter-prompt { + min-width: 0; + max-width: 100%; + padding: 7px 11px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-pill); + background: var(--v2-surface); + color: var(--v2-text-secondary); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + text-align: left; + white-space: normal; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; +} + +.v2-root button.v2-chat__starter-prompt:hover { + background: var(--v2-surface-hover); +} + +@media (max-width: 700px) { + .v2-chat__new-pod { + padding: 16px; + } + .v2-chat__new-pod-actions { + grid-template-columns: minmax(0, 1fr); + } + .v2-root button.v2-chat__new-pod-action, + .v2-chat__new-pod-action { + min-height: 0; + } +} + +/* Read-only banner shown in place of the composer when the viewer can read + an agent-dm pod (§3.7) but isn't a formal member. Styled as a soft note, + not an error — being a read-only observer is the intended state. */ +.v2-chat__readonly { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + margin: 12px 16px 16px; + background: var(--v2-bg-subtle); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); +} + +.v2-chat__readonly-icon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--v2-radius-pill); + background: var(--v2-surface-hover); + color: var(--v2-ink); +} + +.v2-chat__readonly-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.v2-chat__readonly-title { + font-size: 13px; + font-weight: 600; + color: var(--v2-text-primary); +} + +.v2-chat__readonly-text { + font-size: 12.5px; + line-height: 1.45; + color: var(--v2-text-secondary); +} + +/* GitHub PR preview card. Rendered below a message body when the content + contains a `https://github.com///pull/` URL. Card fetches + from GitHub REST API on mount; failure mode is silent (parent's clickable + URL stays). Visual: borders not shadows, one accent color, no gradients — + matches the v2 design system. */ +.v2-prcard { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding: 10px 12px; + background: var(--v2-surface); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + text-decoration: none; + color: var(--v2-text-primary); + transition: border-color 120ms ease, background 120ms ease; +} + +.v2-prcard:hover { + border-color: var(--v2-border); + background: var(--v2-surface-hover); +} + +.v2-prcard--loading { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--v2-text-secondary); + font-size: 12.5px; +} + +.v2-prcard__chip { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 12px; + padding: 2px 8px; + border-radius: var(--v2-radius-pill); + background: var(--v2-accent-soft); + color: var(--v2-accent-text); +} + +.v2-prcard__loading-text { + font-style: italic; +} + +.v2-prcard__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--v2-text-secondary); +} + +.v2-prcard__repo { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + letter-spacing: 0.01em; +} + +.v2-prcard__state { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: var(--v2-radius-pill); + border: 1px solid transparent; +} + +.v2-prcard__state--open { + color: #16a34a; + background: rgba(22, 163, 74, 0.10); + border-color: rgba(22, 163, 74, 0.20); +} + +.v2-prcard__state--merged { + color: #6f42c1; + background: rgba(111, 66, 193, 0.10); + border-color: rgba(111, 66, 193, 0.20); +} + +.v2-prcard__state--closed { + color: #ef4444; + background: rgba(239, 68, 68, 0.10); + border-color: rgba(239, 68, 68, 0.20); +} + +.v2-prcard__title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--v2-text-primary); +} + +.v2-prcard__num { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + color: var(--v2-text-secondary); + font-weight: 500; + margin-right: 4px; +} + +.v2-prcard__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: var(--v2-text-secondary); +} + +.v2-prcard__metric { + font-family: var(--v2-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 11.5px; +} + +.v2-prcard__metric--add { color: #16a34a; } + +.v2-prcard__metric--del { color: #ef4444; } + +.v2-prcard__contributors { + display: inline-flex; + align-items: center; + margin-left: auto; +} + +.v2-prcard__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: -6px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--v2-accent-soft); + border: 2px solid var(--v2-surface); + font-size: 10px; + font-weight: 600; + color: var(--v2-accent-text); +} + +.v2-prcard__avatar:first-child { margin-left: 0; } + +.v2-prcard__avatar--overflow { + background-color: var(--v2-border-soft); + color: var(--v2-text-secondary); + font-size: 10px; +} + +.v2-gig { + min-width: 0; + max-width: 600px; + padding: 12px 16px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + overflow-wrap: anywhere; +} + +.v2-gig__head, +.v2-gig__stack { display: flex; gap: 8px; align-items: baseline; } + +.v2-gig__head { justify-content: space-between; font: 500 11px var(--v2-font-mono); color: var(--v2-text-muted); margin-bottom: 8px; } + +.v2-gig__stack { flex-wrap: wrap; align-items: center; margin-bottom: 8px; } + +.v2-approval { + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + background: var(--v2-surface); + box-shadow: 0 0 0 2px var(--v2-accent); + padding: 12px 16px; + max-width: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.v2-approval__head { + display: flex; + align-items: center; + gap: 8px; +} + +.v2-approval__badge { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--v2-ink); + font-family: var(--v2-font-mono); +} + +.v2-approval__agent { + font-size: 12.5px; + font-weight: 600; + color: var(--v2-text-secondary); +} + +.v2-approval__time { + font-size: 11.5px; + color: var(--v2-text-muted); + margin-left: auto; +} + +.v2-approval__summary { + font-size: 14px; + color: var(--v2-text-primary); + line-height: 1.45; +} + +/* The consent line — server-derived from actionType + params. It outranks the + agent's prose deliberately: the prose is unvalidated and the human is + consenting to what executes, not to what was pitched. */ +.v2-approval__action { + font-size: 14.5px; + font-weight: 600; + color: var(--v2-text-primary); + line-height: 1.4; + overflow-wrap: anywhere; +} + +/* Owner-only canonical tool arguments. Keep the exact JSON readable without + letting a long comment body widen the message column on mobile. */ +.v2-approval__tool-call { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + background: var(--v2-bg); + color: var(--v2-text-primary); + font: 12px/1.45 var(--v2-font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-x: auto; +} + +/* The agent's reason for asking, demoted below the action it belongs to. */ +.v2-approval__pitch { + font-size: 13px; + color: var(--v2-text-secondary); + line-height: 1.45; + padding-left: 10px; + border-left: 2px solid var(--v2-border-soft); + overflow-wrap: anywhere; +} + +.v2-approval__actions { + display: flex; + gap: 8px; + margin-top: 2px; +} + +.v2-root button.v2-approval__btn { + border-radius: var(--v2-radius-sm); + font-size: 13px; + font-weight: 600; + padding: 7px 16px; + cursor: pointer; + border: 1px solid var(--v2-border); +} + +.v2-root button.v2-approval__btn--approve { + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); +} + +.v2-root button.v2-approval__btn--approve:not(:disabled):hover { + background: var(--v2-ink-hover); +} + +.v2-root button.v2-approval__btn--decline { + background: var(--v2-bg); + color: var(--v2-text-secondary); +} + +.v2-root button.v2-approval__btn--decline:not(:disabled):hover { + background: var(--v2-surface-hover); + color: var(--v2-text-primary); +} + +.v2-root button.v2-approval__btn:disabled { + opacity: 0.55; + cursor: default; +} + +.v2-approval__state { + font-size: 13px; + color: var(--v2-text-secondary); + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.v2-approval__state--approved { + color: #047857; + font-weight: 600; +} + +.v2-approval__state--declined { + color: var(--v2-text-tertiary); + font-weight: 600; +} + +.v2-approval__state--attention { + color: #b45309; + font-weight: 600; +} + +.v2-approval__state--expired { + color: var(--v2-text-muted); + font-style: italic; +} + +.v2-root button.v2-approval__result-link { + border: none; + background: none; + color: var(--v2-accent-text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 0; + text-decoration: underline; +} + +.v2-approval__error { + font-size: 12.5px; + color: #b91c1c; +} + +/* Hosted-to-BYO handoff: the command is server-composed and the card copies + the selected string verbatim. Keep it visually distinct from approval cards + without making the shell instructions look like ordinary agent prose. */ +.v2-byo-handoff { + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + background: var(--v2-surface); + box-shadow: 0 0 0 2px var(--v2-accent); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.v2-byo-handoff__head { + display: flex; + align-items: center; + gap: 8px; +} + +.v2-byo-handoff__badge { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--v2-ink); + font-family: var(--v2-font-mono); +} + +.v2-byo-handoff__agent, +.v2-byo-handoff__time { + font-size: 12.5px; + color: var(--v2-text-secondary); +} + +.v2-byo-handoff__time { + margin-left: auto; + font-size: 12px; + color: var(--v2-text-muted); +} + +.v2-byo-handoff__title { + font-size: 15px; + font-weight: 650; + color: var(--v2-text-primary); +} + +.v2-byo-handoff__body, +.v2-byo-handoff__note { + font-size: 13px; + color: var(--v2-text-secondary); + line-height: 1.45; +} + +.v2-byo-handoff__os { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.v2-root button.v2-byo-handoff__os-btn, +.v2-root button.v2-byo-handoff__copy { + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.v2-root button.v2-byo-handoff__os-btn { + padding: 6px 10px; + background: var(--v2-bg); + color: var(--v2-text-secondary); +} + +.v2-root button.v2-byo-handoff__os-btn--selected { + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); +} + +.v2-byo-handoff__command { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + background: var(--v2-bg); + color: var(--v2-text-primary); + font: 12px/1.5 var(--v2-font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-x: auto; +} + +.v2-root button.v2-byo-handoff__copy { + align-self: flex-start; + padding: 7px 14px; + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); +} + +.v2-root button.v2-byo-handoff__copy:hover:not(:disabled) { + background: var(--v2-ink-hover); +} + +.v2-root button.v2-byo-handoff__copy:disabled { + opacity: 0.55; + cursor: default; +} + +.v2-root:lang(zh) .v2-chat__new-pod-title { + letter-spacing: 0; +} + +.v2-root:lang(zh) .v2-chat__delivery-hint, +.v2-root:lang(zh) .v2-chat__readonly-text, +.v2-root:lang(zh) .v2-approval__summary, +.v2-root:lang(zh) .v2-approval__action { + line-height: 1.6; +} + +/* A square ghost icon button; the word lives in aria-label and title. */ +.v2-root button.v2-icon-button { + display: inline-grid; + width: 28px; + height: 28px; + flex: none; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--v2-text-secondary); + cursor: pointer; +} + +.v2-root button.v2-icon-button:hover:not(:disabled) { + background: var(--v2-surface-hover); + color: var(--v2-text-primary); +} + +.v2-root button.v2-icon-button:focus-visible { + outline: none; + box-shadow: var(--v2-focus-ring); +} diff --git a/frontend/src/v2/components/V2Composer.tsx b/frontend/src/v2/components/V2Composer.tsx index 6a002fa2f..b01219a53 100644 --- a/frontend/src/v2/components/V2Composer.tsx +++ b/frontend/src/v2/components/V2Composer.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import V2Avatar from './V2Avatar'; +import { ShellGlyph } from '../icons/glyphs'; import type { V2Message } from '../hooks/useV2PodDetail'; export interface V2ComposerMention { @@ -150,7 +151,9 @@ const V2Composer: React.FC = ({ aria-expanded={menuOpen} onClick={() => setMenuOpen((open) => !open)} disabled={uploading} - /> + > + + {menuOpen && (
@@ -206,18 +209,18 @@ const V2Composer: React.FC = ({ )}
{uploading && {t('podChat.composer.uploading')}} - {hasText && ( - - )} + {/* D215 draws Send at rest. With no text it is disabled, keeping its + name, so it never sends an empty draft. */} + = ({ detail, currentUserId, onPodC )} {attention.map((item) => ( - {pickerOpen && ( @@ -127,7 +127,7 @@ const V2MessageActions: React.FC = ({ title={t('podChat.thread.startThread')} onClick={() => onThread(message)} > - + )} diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 45919969d..76dc1945e 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -194,15 +194,8 @@ const TypingIndicator: React.FC<{ agents: TypingAgentEntry[] }> = ({ agents }) = }); return (
-
- {agents.slice(0, 3).map((a) => ( - - ))} -
+
); }; diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 0a07b554b..cec8060bf 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -7,6 +7,9 @@ import { ThreadViewItem } from '../utils/threadView'; import { isGroupedWithPrevious } from '../utils/messageGrouping'; import V2MessageRow from './V2MessageRow'; import V2ThreadCard from './V2ThreadCard'; +import V2Avatar from './V2Avatar'; +import { ShellGlyph } from '../icons/glyphs'; +import { formatRelativeTime } from '../utils/grouping'; import { V2DecisionCardData, V2DecisionRuling } from './V2DecisionCard'; // Expanded threads show the newest eight; a `N more replies` line reveals the rest. @@ -376,20 +379,51 @@ const V2ThreadMessages: React.FC = ({ const open = openClaimRuns.has(item.key); const first = item.messages[0].payload?.taskId ?? ''; const last = item.messages[item.messages.length - 1].payload?.taskId ?? ''; + // D215 .sumrow: up to four distinct claimers' faces and the run's + // last time. The accessible name stays the one-line label. + const claimers = item.messages.filter((message, claimIndex) => item.messages + .findIndex((other) => (other.user_id || other.payload?.actorName) === (message.user_id || message.payload?.actorName)) === claimIndex) + .slice(0, 4); + const lastAt = item.messages[item.messages.length - 1].created_at; return (
- {open && item.messages.map((message, claimIndex) => renderMessage( - message, - claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] }, - ))} + {open && ( +
+ {item.messages.map((message, claimIndex) => renderMessage( + message, + claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] }, + ))} +
+ )}
); } @@ -435,7 +469,14 @@ const V2ThreadMessages: React.FC = ({ /> )} {!collapsed && ( -
+
{hiddenReplies > 0 && ( - {item.replyCount} {item.replyCount === 1 ? t('podChat.thread.replyOne') : t('podChat.thread.replyOther')} {onReply && ( )} + + {item.replyCount} {item.replyCount === 1 ? t('podChat.thread.replyOne') : t('podChat.thread.replyOther')}