From e1498c8ef0af9a9284d13548e553926b4dac26ac Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:02:58 -0800 Subject: [PATCH 1/5] feat(v2): chat message row, lifted from the d215 frame (S3a) What moved: every chat-only message-row rule leaves v2.css for frontend/src/v2/chat/chat-message.css (169 selectors, about 960 lines): .v2-msg* row, head, body, fold, Show more, mention, quote, attachments, reactions, picker, more menu, hover strip, syscard, system action line and claim run. The selector set comes from the v2css surface map's classifier re-run at 540337d18, widened to chat-scoped overrides on shared subjects (.v2-thread .v2-msg, .v2-msg.v2-msg--card). Rules the landing DemoWorkspace or uiCard also use (.v2-msg, .v2-decision-card) stay in v2.css. V2App.tsx imports the file right after v2.css and primitives.css. A component-level import would load before v2.css, because V2Layout is imported above it, and every moved rule would lose the order ties it won inside v2.css. A pair-wise order check found no flip that two rules apply to the same element. A new test reader, __testkit__/v2Css.ts, returns v2.css plus the lifted files in import order. The invariants (22 reads), type floors and copy budget read through it. Redrawn to the frame (.msg .mbody .cd .acts .sumrow .sumlist): - body: line-height 1.55 -> 22px, colour message-text -> ink-2, p 0 0 6 -> 0 0 8, lists 4 0 6 / 22 -> 0 0 8 / 18 - inline code: the .cd chip (mono 12.5, tint, r6, pad 2 5, soft inset ring) - hover strip: top -14 -> 2, pad 1 -> 2, 1px border + r4 -> r9 on the pop shadow; keys 24px r3 -> 28px r8 ghosts with 17px glyphs; the reaction and thread glyphs are now the frame's - claim run: the 24px chip becomes the 34px bar in the text column (board glyph, up to four 20px faces, bold count, range, run time, chevron); the open list is the 26px .sumlist rows. The button's accessible name is unchanged (aria-label = the old one-line label). New strings podChat.claimRun.count / .range in en and zh-CN. - 390: 30px faces, 10px gap. Name, meta and body take the frame's 14 / 11.5 / 14-on-22. The 15px phone type predated the frame (#1556). Exceptions to the frame: - the drawn 28px strip keys keep a 44px hit area (::after, 8px each side) on (hover: none), (pointer: coarse) and at <=760px. The strip gap goes 2 -> 16px there, so neighbouring hit areas meet without overlapping. - the fold keeps the #3055-held mask-image fade, not the frame's ::after gradient to --sf. - the mention keeps #3173's color-mix(accent 10%) fill, not --c-soft. The accent-soft ban in chat stays in force. - the claim chevron is text-muted, not --ph. Measured: --ph on the bar is 2.46:1 (dark 3.01:1); muted is 4.76:1 (dark 5.48:1); marks need 3:1. Carried unchanged (not drawn in the frame yet): row hover wash, grouped rows and gutter time, landed flash, quote, attachments, reactions and picker, more menu, syscard, the system line outside a run, the claim toggle's hover and focus-visible, the code-block collapse, zh line-height. Deleted rather than out-ranked: - .v2-thread .v2-msg__actions { border-radius: 4px; box-shadow: none } - the two 44px .v2-root button.v2-msg__action sizes in the coarse and 640px blocks. The .v2-thread 24px rule already out-ranked them at every width, so they were dead. Invariants changed: - the threading-restyle strip lines now pin r9 / pop shadow / pad 2 and the 28px keys - the 760px 24px-key pin becomes the 28px key + hit area - the 44px touch test pins the hit areas instead of 44px keys - artboard-grammar actions now read .v2-thread .v2-msg__strip - the claim-run test pins the bar (34px, subtle fill, soft ring) and the muted chevron - the zh line-height test reads each selector's own rule, because the lift splits the shared :lang(zh) lists - the capability-block order check searches after the strip rule New: a V2ClaimRun render test for the bar's faces, count, range, time and list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/i18n/locales/en.json | 4 +- frontend/src/i18n/locales/zh-CN.json | 4 +- frontend/src/v2/V2App.tsx | 6 + frontend/src/v2/__testkit__/v2Css.ts | 14 + frontend/src/v2/__tests__/V2ClaimRun.test.tsx | 14 + .../src/v2/__tests__/v2-copy-budget.test.tsx | 3 +- .../v2/__tests__/v2-layout-invariants.test.ts | 107 +- .../src/v2/__tests__/v2-type-floors.test.ts | 6 +- frontend/src/v2/chat/chat-message.css | 1110 +++++++++++++++++ .../src/v2/components/V2MessageActions.tsx | 4 +- .../src/v2/components/V2ThreadMessages.tsx | 44 +- frontend/src/v2/v2.css | 991 +-------------- 12 files changed, 1274 insertions(+), 1033 deletions(-) create mode 100644 frontend/src/v2/__testkit__/v2Css.ts create mode 100644 frontend/src/v2/chat/chat-message.css diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f4f14cd8b9..447ab5d9d2 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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 51dcc5beb5..7d8cf95a59 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": "开始方式", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 14c88bf667..322371691e 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -17,6 +17,12 @@ 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 { 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 0000000000..2cd6131e6c --- /dev/null +++ b/frontend/src/v2/__testkit__/v2Css.ts @@ -0,0 +1,14 @@ +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', +]; + +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 7d08c2b4dc..a21770a583 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__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx index d171372316..0e03460f92 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,7 +52,7 @@ 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'); + const css = readV2Css(); expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .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; }'); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 19c13c8e38..2b31df4f57 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. @@ -160,7 +161,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'); @@ -1477,14 +1478,17 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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'); + // 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'); @@ -1502,8 +1506,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. @@ -1686,7 +1691,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)'); @@ -1697,8 +1702,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"'); @@ -2286,7 +2292,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/); @@ -2338,17 +2349,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 @@ -3069,7 +3083,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', () => { @@ -3187,7 +3201,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'); @@ -3228,7 +3242,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 byo = read('../components/V2AgentBYO.tsx'); const en = read('../../i18n/locales/en.json'); @@ -3484,7 +3498,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 byo = read('../components/V2AgentBYO.tsx'); const en = read('../../i18n/locales/en.json'); const zh = read('../../i18n/locales/zh-CN.json'); @@ -3702,7 +3716,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; @@ -5014,7 +5028,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(); test('the command box breaks inside a word only when the word cannot fit', () => { const rule = ruleBody(v2, '.v2-byo__pre'); expect(rule).toContain('white-space: pre-wrap'); @@ -5039,7 +5053,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'); @@ -5125,7 +5139,7 @@ describe('agent Memory tab (TASK-690 slice 1 and 2)', () => { }); 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(css, '.v2-board__priority'); expect(badge).toContain('var(--v2-font-mono)'); @@ -5178,7 +5192,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(css, '.v2-board .v2-board__detail'); expect(sheet).toContain('inset: 44px 0 0'); const modal = ruleBody(css, '.v2-modal'); @@ -5189,7 +5203,7 @@ test('TASK-443 phone ownership sheet keeps its inset and scroll containment', () }); test('TASK-699 task detail is a 400px right drawer on desktop and a sheet on phones', () => { - const css = read('../v2.css'); + const css = readV2Css(); expect(ruleBody(css, '.v2-board .v2-board__drawer')).toContain('width: 400px'); expect(ruleBody(css, '.v2-board .v2-board__overlay--drawer')).toContain('justify-content: flex-end'); expect(read('../components/V2PodBoard.tsx')).toContain('className="v2-modal v2-board__detail v2-board__drawer"'); @@ -5197,12 +5211,12 @@ test('TASK-699 task detail is a 400px right drawer on desktop and a sheet on pho test('TASK-443 lease typography outranks the ownership cell font size', () => { - expect(ruleBody(read('../v2.css'), '.v2-board__ownership .v2-board__lease-value')) + expect(ruleBody(readV2Css(), '.v2-board__ownership .v2-board__lease-value')) .toContain('font: 500 12px/16px var(--v2-font-mono)'); }); test('TASK-482 sticky bar overlays reserved space with a top border and no shadow', () => { - const css = read('../v2.css'); + const css = readV2Css(); const bar = ruleBody(css, '.v2-byo__hosted-offer'); expect(bar).toContain('position: sticky'); expect(bar).toContain('bottom: 0'); @@ -5249,7 +5263,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'); @@ -5296,7 +5310,7 @@ test('OpenUI cards keep the approved phone controls and table records', () => { describe('v2 board on a phone (TASK-576)', () => { test('the board head wraps so the title keeps a full-width row above the actions', () => { - const css = read('../v2.css'); + const css = readV2Css(); const phone = mediaBlockContaining(css, '.v2-board__head'); expect(ruleBody(phone, '.v2-board__head')).toContain('flex-wrap: wrap'); expect(ruleBody(phone, '.v2-board__title-wrap')).toContain('flex: 1 1 100%'); @@ -5305,7 +5319,7 @@ describe('v2 board on a phone (TASK-576)', () => { }); test('the board drops the feature body gutter on a phone', () => { - const css = read('../v2.css'); + const css = readV2Css(); const phone = mediaBlockContaining(css, '.v2-feature--board > .v2-feature__body'); expect(ruleBody(phone, '.v2-feature--board > .v2-feature__body')).toMatch(/padding:\s*0;/); }); @@ -5317,21 +5331,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\);/, ); }); @@ -5347,16 +5361,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', () => { // The feature body was the scroller and its legacy wrapper had auto height, so the // columns' max-height:100% resolved to nothing (85.5x at 1440, measured 12f608b71). @@ -5380,14 +5399,14 @@ 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'); + 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']) { expect(phoneHead).toContain(`.v2-root button.${key}::after`); } @@ -5403,7 +5422,7 @@ describe('pod board column scroll (TASK-698)', () => { }); test('the board keeps three columns plus a 120px done rail, and tabs only on phones (TASK-687)', () => { - const css = fs.readFileSync(path.join(__dirname, '..', 'v2.css'), 'utf8'); + const css = readV2Css(); expect(css).toMatch(/\.v2-board__columns \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\) 120px/); expect(css).toMatch(/\.v2-board__tabs \{ display: none; \}/); // Card actions stay on the id row (off the title); card buttons keep a visible focus ring. diff --git a/frontend/src/v2/__tests__/v2-type-floors.test.ts b/frontend/src/v2/__tests__/v2-type-floors.test.ts index 36f1af4ce5..0351d7bd0f 100644 --- a/frontend/src/v2/__tests__/v2-type-floors.test.ts +++ b/frontend/src/v2/__tests__/v2-type-floors.test.ts @@ -1,5 +1,4 @@ -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,7 +23,8 @@ 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. */ -const CSS = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8'); +// v2.css plus the surface files lifted out of it (D223). +const CSS = readV2Css(); const MONO_FLOOR = 11; const SANS_FLOOR = 12; diff --git a/frontend/src/v2/chat/chat-message.css b/frontend/src/v2/chat/chat-message.css new file mode 100644 index 0000000000..74647d7ba9 --- /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/components/V2MessageActions.tsx b/frontend/src/v2/components/V2MessageActions.tsx index 7be2f9b8b3..98c0394683 100644 --- a/frontend/src/v2/components/V2MessageActions.tsx +++ b/frontend/src/v2/components/V2MessageActions.tsx @@ -86,7 +86,7 @@ const V2MessageActions: React.FC = ({ onClick={onTogglePicker} disabled={!canInteract} > - } /> + } /> {pickerOpen && ( @@ -127,7 +127,7 @@ const V2MessageActions: React.FC = ({ title={t('podChat.thread.startThread')} onClick={() => onThread(message)} > - + )} diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 0a07b554b3..b3b682b10f 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] }, + ))} +
+ )}
); } diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index ca97177bfd..636661048d 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -2483,18 +2483,6 @@ body.modern-ui.v2-canvas { flex-shrink: 0; } -/* 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; -} - /* Agent typing indicator — sits between message list and composer. Collapses to nothing when typingAgents is empty. Subtle so it doesn't compete with the messages above; the animated dots carry the "alive" cue. */ @@ -3001,177 +2989,6 @@ body.modern-ui.v2-canvas { color: var(--v2-text-secondary); } -/* 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); -} - -/* A run of claim lines folds to one row. The toggle is a control, so it - carries the same chevron pattern as the long-message Show more. */ -.v2-root button.v2-claim-run__toggle { - display: inline-flex; - align-items: center; - gap: 5px; - height: 24px; - margin: 4px 0; - padding: 0 8px; - 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-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::after { - content: ''; - width: 6px; - height: 6px; - margin-left: 2px; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(45deg) translateY(-2px); - transition: transform 160ms; -} -.v2-claim-run__toggle[aria-expanded='true']::after { transform: rotate(225deg) translateY(-2px); } - -/* §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); -} - /* 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 @@ -3395,14 +3212,6 @@ body.modern-ui.v2-canvas { padding: 10px 0; } -/* 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; } - /* The message action cluster (taste rulings 2026-08-23): Reply, Thread, and the reaction trigger share ONE floating pill at the row's top-right, identical for headed and grouped rows. Absolute + hover means @@ -3411,241 +3220,6 @@ body.modern-ui.v2-canvas { the trigger sits with its siblings instead of orphaned in an empty band below the message (the old absolute "+"). */ .v2-msg { position: relative; } -.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 { - font-size: 14px; - line-height: 1.55; - color: var(--v2-message-text); - 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 6px; - 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: 4px 0 6px; - padding-left: 22px; -} - -.v2-msg__content li { - margin: 2px 0; -} - -.v2-msg__content code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12.5px; - background: var(--v2-surface-tint); - padding: 1px 5px; - border-radius: 4px; -} - -.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__image-link { display: inline-block; margin-top: 7px; @@ -3709,30 +3283,6 @@ body.modern-ui.v2-canvas { font-size: 11px; } -.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); -} - /* ---------- Inspector ---------- */ .v2-inspector { @@ -6186,154 +5736,26 @@ body.modern-ui.v2-canvas { .v2-chat__state-strip { grid-template-columns: 1fr; - gap: 4px; - } -} - -/* Plan/Execute pill in pod chat header (replaces banner state-strip) */ -.v2-chat__mode-toggle--header { - height: 32px; - margin-right: 4px; -} -.v2-chat__mode-toggle--header .v2-chat__mode-option { - height: 24px; - font-size: 11.5px; -} -.v2-chat__mode-toggle--execute { - border-color: rgba(204, 92, 28, 0.4); - background: rgba(252, 232, 220, 0.55); -} -.v2-chat__goal-meta { - color: var(--v2-text-muted); - font-weight: 500; -} - -/* 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; + gap: 4px; + } } -.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; +/* Plan/Execute pill in pod chat header (replaces banner state-strip) */ +.v2-chat__mode-toggle--header { + height: 32px; + margin-right: 4px; } -.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-chat__mode-toggle--header .v2-chat__mode-option { + height: 24px; + font-size: 11.5px; } -.v2-root button.v2-msg__reaction-picker-item:hover { - background: #f3f4f6; +.v2-chat__mode-toggle--execute { + border-color: rgba(204, 92, 28, 0.4); + background: rgba(252, 232, 220, 0.55); } -.v2-root button.v2-msg__reaction-picker-item--mine { - background: var(--v2-surface); - border: 1px solid var(--v2-ink); +.v2-chat__goal-meta { + color: var(--v2-text-muted); + font-weight: 500; } /* Sprint B3 — Bring-your-own MCP agent onboarding page. Lives at @@ -7543,35 +6965,7 @@ body.modern-ui.v2-canvas { font-size: 16px !important; } - /* 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; - } - .v2-root button.v2-msg__action { - width: 44px; - height: 44px; - } -} - -.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; } .v2-chat__reply-chip { display: flex; @@ -7784,7 +7178,6 @@ body.modern-ui.v2-canvas { padding-top: 40px; } - .v2-settings__section { min-width: 0; } .v2-settings__label { @@ -8399,7 +7792,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-settings__look .v2-settings__row > :last-child { margin-left: auto; } .v2-settings__look .v2-settings__row + .v2-settings__row { border-top: 1px solid var(--v2-border-soft); } - /* Settings owns the connected-apps controls without inheriting the legacy * page chrome: its section label is the only page-level heading. */ .v2-settings .apps-management--settings { @@ -9285,14 +8677,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor ring identifies an unresolved human decision without turning the card into generic tinted chrome. */ -/* 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; -} - .v2-gig { min-width: 0; max-width: 600px; @@ -9313,10 +8697,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-gig__list { margin: 0 0 8px; padding-left: 20px; } .v2-gig__link { color: var(--v2-accent-text); text-decoration: underline; text-underline-offset: 2px; font-size: 12px; } .v2-gig__link:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); } -@media (max-width: 760px) { - .v2-msg.v2-msg--card:has(.v2-gig) { padding-left: 0; } -} - .v2-approval { border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius); @@ -9808,26 +9188,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor } @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 button.v2-msg__action { - width: 44px; - height: 44px; - } - .v2-thread-card { flex-wrap: wrap; } @@ -9889,7 +9249,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor letter-spacing: -0.03em; } - .v2-activity__controls, .v2-activity__window, .v2-activity__agent-topline, @@ -10185,7 +9544,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor margin-bottom: 8px; } - .v2-activity__count { color: var(--v2-text-secondary); font: 500 11px/16px var(--v2-font-mono); @@ -10914,8 +10272,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor * legibility floor. Single-line nowrap+ellipsis rows are not touched — their * height is not text-driven. Guarded by v2-layout-invariants.test.ts. * ------------------------------------------------------------------------ */ -.v2-root:lang(zh) .v2-msg__content, -.v2-root:lang(zh) .v2-msg__content pre, .v2-root:lang(zh) .v2-chat__composer-hint, .v2-root:lang(zh) .v2-chat__delivery-hint, .v2-root:lang(zh) .v2-chat__readonly-text, @@ -11741,11 +11097,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-decision-card__settled { color: var(--v2-text-secondary); } .v2-decision-card__error { color: #b91c1c; } -.v2-msg__ruled { - color: var(--v2-text-muted); - font: 500 11px/16px var(--v2-font-mono); -} - /* ---------- Workspace thread and composer (TASK-129 PR 3) ---------- */ .v2-thread__header { @@ -11805,7 +11156,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor /* 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. */ @@ -11841,15 +11191,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor } } -.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-avatar { width: 36px; height: 36px; @@ -11857,18 +11198,8 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor border-radius: var(--v2-r-9, 9px); box-shadow: 0 0 0 1px var(--v2-av-ring, rgba(16, 24, 40, 0.08)); } -.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); } /* The typing line keeps small faces; the 36px message face is not its size. */ .v2-thread .v2-chat__typing-avatars .v2-avatar { width: 20px; height: 20px; border-radius: var(--v2-r-6, 6px); } -.v2-thread .v2-msg__actions { border-radius: 4px; box-shadow: none; } - -.v2-message-row--decision { padding: 8px 0; } - .v2-composer__error { margin: 0; padding: 0 10px 8px; @@ -11898,12 +11229,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor 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; } - .v2-thread .v2-msg__author, - .v2-thread .v2-msg__author-btn { font-size: 15px; } - .v2-thread .v2-msg__time, - .v2-thread .v2-msg__ruled { font-size: 12px; } - .v2-thread .v2-msg__content { font-size: 15px; line-height: 22px; } - /* The phone artboard makes every decision option a full-width, 44px row; Other remains a centred text escape hatch beneath them. */ .v2-decision-card__options { align-items: stretch; flex-direction: column; } @@ -12137,7 +11462,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-root button.v2-pods__new[aria-expanded="true"] { background: var(--v2-surface-hover); } - /* ---------- D215 sidebar controls (TASK-691) ---------- Controls look like controls: an icon button, a chip or a segment, never grey words that click. The segment is shared by the pod list (Recent · All · @@ -12145,7 +11469,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor /* The segment's look is the primitives' v2p-seg; these keep its states. */ .v2-root button.v2-seg__option:hover:not(:disabled) { color: var(--v2-text-primary); } - .v2-root button.v2-seg__option:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); @@ -12516,8 +11839,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-root button.v2-pod-header__rename-open { opacity: 1; } } - - .v2-root button.v2-thread__back { display: none; } @media (max-width: 760px) { @@ -12727,7 +12048,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-composer__plus-wrap { position: relative; flex: 0 0 auto; align-self: flex-end; } - .v2-root button.v2-composer__plus::before { content: '+'; font: 500 18px/1 var(--v2-font); } /* 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); } @@ -12851,144 +12171,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor .v2-composer__error, .v2-composer__warnings { padding: 0 10px 8px; } -/* 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; } - /* Lightbox */ .v2-lightbox { position: fixed; @@ -13142,7 +12324,6 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor /* 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-root button.v2-msg__thumb { width: calc(50% - 3px); height: 84px; } .v2-composer__tag { max-width: 120px; } } @@ -13153,121 +12334,8 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor top-right on hover (inline under the body on long-press at 390); the Jump pill is white with a cobalt count; transcript avatars are two-tone. */ -/* 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; } - /* Two-tone avatars inside the transcript: agents cobalt, humans tint. */ -/* 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); } - -/* Action strip: 28px white box, r4, four 24px icons, top-right on hover. */ -.v2-thread .v2-msg__strip { - top: -14px; - right: 0; - gap: 2px; - padding: 1px; - border: 1px solid var(--v2-border-soft); - border-radius: 4px; - box-shadow: none; -} -.v2-root .v2-thread button.v2-msg__action { width: 24px; height: 24px; border-radius: 3px; color: #4f5c6e; } -.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); } - /* 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 { @@ -13345,37 +12413,8 @@ details[open] > .v2-settings__summary > .v2-settings__icon:last-child { transfor /* Composer aim chip (walk-3 miss 59). */ .v2-composer__aim { background: var(--v2-accent-soft, #e8ecfb); color: var(--v2-accent-text); } -/* 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. */ - .v2-root .v2-thread button.v2-msg__action { width: 24px; height: 24px; } .v2-root .v2-thread button.v2-msg__thread-chip { min-height: 32px; } - .v2-thread .v2-msg__gutter-time { display: none; } } /* Direction C Activity board: a 52px bar over a readable 760px column. From b60911155339d9432f4fbe74f9931f024f642ded Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:36:49 -0800 Subject: [PATCH 2/5] feat(v2): chat pane, stream, thread and presence, lifted from the d215 frame (S3b) What moved: 150 chat-only selectors (pane, stream, thread block, thread card, replies, presence, pod header, history/jump/edge rows) from v2.css to chat/chat-pane.css, imported in V2App right after chat-message.css so they keep winning the order ties they won inside v2.css. The order-flip check across v2.css + both chat files reports one tie, on disjoint elements (.v2-thread .v2-avatar vs .v2-pods__row-mark.v2-avatar). Redrawn to the frame: - Stream padding 10 28 6; phone 8 12 4 (was 0 28 12 / inline 14). - Thread block (#3178 / TASK-745): the band behind root + replies and the rail beside the replies are gone. The root is a plain transcript row; the replies are the .thr card (bg-subtle, inset border-soft ring, r10, padding 10 12 8, grid gap 8). Phone: 40px indent, padding 8 10 8. The replies carry role="group" with "Thread, N replies" (new podChat.thread.groupLabel, en + zh-CN). - Reply rows: 24px / gap 10, head 18, name 13, body 13.5/20, time 11.5. - Foot: Reply in thread (secondary sm key, new reply glyph, aria-label kept), Collapse (ghost sm key, chevron turned up), count pushed right in mono 11.5 muted. Collapse/expand and jump-to-message unchanged; the --open class stays as the state hook. - Presence: the typing row is the .presence chip (24px, bg-subtle, inset ring, mono 11.5 secondary) with a 7px cobalt dot whose pulse uses --motion-pulse and stops under reduced motion. Avatars and three dots removed; aria-live and the label text unchanged. Ruling 9: the unscoped .v2-thread-replies .v2-msg / :first-child / --grouped / __head / __author / __time overrides, the 640px inner-24px and replies padding-left rules, the band rules, the bespoke __aim and __collapse looks, and the typing-avatars rule are deleted, not out-ranked. v2-typing-dot keyframes stay (the boot mark uses them). Exceptions: - Phone foot keys are drawn 32px; an ::after ring (inset -6px) takes the hit area to 44px. Follow is not drawn, keeps its look and its 44px min-height on phone, and sits 8px after the count (was margin auto). - Presence keeps its slot between the scroller and the composer (moving it into the scroller would touch history anchoring), so its left edge is 28 + 48 = 76px (phone 12 + 40 = 52px). - Desktop foot keys are the drawn 28px (v2p-b-sm). Tests changed: - v2-layout-invariants: typing-avatars size -> presence dot; 640 inner 24px -> 760 inner 40px; typing-dots motion -> presence pulse uses --motion-pulse; band/rail -> .thr card values and no border-left; rail geometry test rewritten to card + reply-row numbers and asserts the deleted overrides stay gone; phone stream padding 8 12 4; cobalt allowlist adds .v2-chat__typing-dot (the frame's presence dot); the zh letter-spacing reset is read from every :lang(zh) reset rule, since a lifted surface carries its own; the phone hit-area check reads the header and composer halves of the old shared rule separately. - v2-copy-budget: the hint display:contents rule is checked per half. - e2e/reviewer-journey.spec.ts was already broken on main (classes that no longer exist); not touched here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015AKgpMD6Qh13f7fj6mbGT3 --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + frontend/src/v2/V2App.tsx | 1 + frontend/src/v2/__testkit__/v2Css.ts | 1 + .../src/v2/__tests__/v2-copy-budget.test.tsx | 4 +- .../v2/__tests__/v2-layout-invariants.test.ts | 79 +- frontend/src/v2/chat/chat-pane.css | 770 ++++++++++++++++++ frontend/src/v2/components/V2Thread.tsx | 9 +- .../src/v2/components/V2ThreadMessages.tsx | 21 +- frontend/src/v2/icons/glyphs.tsx | 4 +- frontend/src/v2/v2.css | 742 +---------------- 11 files changed, 847 insertions(+), 786 deletions(-) create mode 100644 frontend/src/v2/chat/chat-pane.css diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 447ab5d9d2..7f91df7253 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -766,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 7d8cf95a59..e83ccc18c8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -765,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 322371691e..403a5a9b4a 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -23,6 +23,7 @@ import './primitives/primitives.css'; // 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 { 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 index 2cd6131e6c..8195f296a5 100644 --- a/frontend/src/v2/__testkit__/v2Css.ts +++ b/frontend/src/v2/__testkit__/v2Css.ts @@ -7,6 +7,7 @@ import path from 'path'; export const V2_CSS_FILES = [ 'v2.css', 'chat/chat-message.css', + 'chat/chat-pane.css', ]; export const readV2Css = (): string => V2_CSS_FILES diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx index 0e03460f92..8dea06a80d 100644 --- a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx +++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx @@ -53,7 +53,9 @@ describe('page head copy budget', () => { test('phone disclosure keeps its trigger in the title row and uses the shared focus ring', () => { const css = readV2Css(); - expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .v2-thread__title-line .v2-hint--open { display: contents; }'); + // 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 2b31df4f57..0e3cf104e4 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -598,8 +598,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; \}/); @@ -1026,7 +1028,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'); @@ -1055,7 +1058,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)'); }); @@ -1476,8 +1481,14 @@ 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)'); + // 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)'); @@ -2122,6 +2133,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. @@ -2246,22 +2259,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', () => { @@ -2325,8 +2339,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}`); @@ -2994,7 +3012,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)/); }); @@ -5406,15 +5425,15 @@ describe('pod board column scroll (TASK-698)', () => { 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). + // 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']) { - 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(); - 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. diff --git a/frontend/src/v2/chat/chat-pane.css b/frontend/src/v2/chat/chat-pane.css new file mode 100644 index 0000000000..8698aa9013 --- /dev/null +++ b/frontend/src/v2/chat/chat-pane.css @@ -0,0 +1,770 @@ +/* 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; } + +.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; } + .v2-root .v2-thread-replies__foot .v2p-b-sm::after { content: ''; position: absolute; inset: -6px; } +} diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 45919969df..76dc1945ef 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 b3b682b10f..cec8060bfe 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -469,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')} {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) => ( -