diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 05ed97a7b..a66baa26e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1874,6 +1874,9 @@ "kickerAttention": "attention", "kickerMirror": "mirror", "kickerOff": "relay off", + "rowAttention": "escalations reach the channel", + "rowMirror": "every agent line reaches the channel", + "rowRelayOff": "messages stay in the pod", "age": { "added": "added {{age}}", "started": "started {{age}}", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3d861a98a..2c25896f6 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1866,6 +1866,9 @@ "kickerAttention": "提醒", "kickerMirror": "镜像", "kickerOff": "中继已关", + "rowAttention": "升级消息会回到频道", + "rowMirror": "智能体的每条消息都会回到频道", + "rowRelayOff": "消息只留在 Pod 内", "age": { "added": "{{age}}前添加", "started": "{{age}}前开始", diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index c0f1df3e2..94d90b450 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -6,6 +6,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { MemoryRouter, Route, Routes } from 'react-router-dom'; import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage'; import { AuthContext } from '../../context/AuthContext'; +import en from '../../i18n/locales/en.json'; jest.mock('axios', () => { const mock = { @@ -109,11 +110,47 @@ describe('V2ConnectorsPage', () => { const mark = row?.querySelector('.v2-connector-row__mark'); expect(mark).not.toBeNull(); expect(mark).toHaveAttribute('role', 'img'); - expect(mark?.getAttribute('aria-label')).toMatch(/attention|every agent line|relay off/); + // TASK-162 (3): the glyph carries the MODE WORD (what the mark means); the + // consequence is the visible words beside it, so line 3 is never a bare mark. + expect(mark?.getAttribute('aria-label')).toMatch(/^(attention|mirror|relay off)$/); + expect(mark?.getAttribute('title')).toBe(mark?.getAttribute('aria-label')); + expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).toBe('messages stay in the pod'); + // The mode word is NOT repeated on line 3: at ≤760 the kicker carries it, so + // 'relay off · messages stay in the pod' said it twice (TASK-162 UX gate). + expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).not.toMatch(/^(attention|mirror|relay off) ·/); expect(row?.querySelector('.v2-connector-row__kicker-mode')?.textContent).toMatch(/attention|mirror|relay off/); expect(row?.querySelector('.v2-connector-row__when')).toBeNull(); }); + it('TASK-162: line 3 never repeats the mode word the kicker already carries', async () => { + // The prefixes lived in the component's defaultValue, which is exactly where + // they could not be guarded: once the key exists in the catalog the catalog + // wins, so reverting the defaultValue changes nothing rendered. The copy now + // lives in en.json, so that is where the rule is asserted. + const kickerByRow: Array<[keyof typeof en.connectors, string]> = [ + ['rowAttention', 'attention'], + ['rowMirror', 'mirror'], + ['rowRelayOff', 'relay off'], + ]; + for (const [key, modeWord] of kickerByRow) { + expect(en.connectors[key]).toBeTruthy(); + expect(en.connectors[key].startsWith(modeWord)).toBe(false); + } + }); + + it('TASK-162: the not-yet channel names are separate elements, with a separator that only shows at ≤760', async () => { + mockGets([]); + const { container } = renderPage(); + await waitFor(() => expect(container.querySelector('.v2-connector-row--not-yet')).not.toBeNull()); + const names = container.querySelector('.v2-connector-row--not-yet .v2-connector-row__names'); + expect(names).not.toBeNull(); + // Separate elements, not one joined string: above 760 they stack in the name + // track, so a joined string would be a single 140px-wide line that runs into + // the details column. + expect(Array.from(names!.querySelectorAll('.v2-connector-row__name-item')).map((item) => item.textContent)).toEqual(['Discord', 'WhatsApp']); + expect(names!.querySelectorAll('.v2-connector-row__name-sep')).toHaveLength(1); + }); + it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => { // Fake the clock so the minute tick is deterministic. The fixture's age // only moves if the page re-renders it, which is the defect being pinned. @@ -145,7 +182,10 @@ describe('V2ConnectorsPage', () => { expect(screen.getByText('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument(); expect(screen.getByText('Code expires in 5 min')).toBeInTheDocument(); expect(screen.getByText('Rewire crew · linked to Ops')).toBeInTheDocument(); - expect(screen.getByText('Discord · WhatsApp')).toBeInTheDocument(); + // TASK-162 (2): the two not-yet names are separate elements that stack in the + // name track above 760 and join with ' · ' at ≤760 — no longer one string. + expect(screen.getByText('Discord')).toBeInTheDocument(); + expect(screen.getByText('WhatsApp')).toBeInTheDocument(); expect(screen.getByText('/commonly-enable abc1 23')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy command' })).toBeInTheDocument(); expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(3); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 007ab92c9..f20f47ed4 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1822,14 +1822,54 @@ describe('v2 layout invariants (CSS rule presence)', () => { // Direction A (2026-09-19): three tracks — the age moved into the kicker. expect(ruleBody(v2, '.v2-connector-row')).toContain('grid-template-columns: 140px minmax(150px, 1fr) 120px'); expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('grid-template-columns: 140px minmax(150px, 1fr)'); - // Rule 3: the kicker is mono 11; rule 1: the mark is 16px and the mode word is hidden until 760; rule 2: the gear is 32 (44 on the phone). + // TASK-162 (2): the name track ends exactly where the details track begins, so + // a name that fills it touches the sentence. The gutter has to be on BOTH row + // grids — the article's and the selection button's inner one. + expect(ruleBody(v2, '.v2-connector-row')).toContain('column-gap: 12px'); + expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('column-gap: 12px'); + // TASK-162 (1): the ink hover must NOT claim a secondary action, or a hovered + // gear is ink behind an ink glyph. Asserted on the SELECTOR rather than on a + // computed style: jsdom resolves no :hover rules at all, so the cascade this + // bug lived in cannot be measured in-suite — only the shape that fixes it can. + const inkHoverChunks = [...v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter(([, , body]) => body.includes('var(--v2-ink-hover)')) + .flatMap(([, selector]) => selector.split(',')) + .map((selector) => selector.trim()) + .filter((selector) => selector.includes('.v2-connector-row__action:hover')); + // Split per SELECTOR, not per rule: both variants share one declaration block, + // so a rule-level check is satisfied by whichever selector kept the exclusion + // and reads green with the button — the gear that actually broke — still + // claiming secondary. Two separate assertions, one per line. + expect(inkHoverChunks).toHaveLength(2); + expect(inkHoverChunks.find((selector) => selector.includes('button.v2-connector-row__action:hover'))).toContain(':not(:disabled)'); + for (const selector of inkHoverChunks) { + expect(selector).toContain(':not(.v2-connector-row__action--secondary)'); + } + // TASK-162 (3): line 3 is the consequence as TEXT beside the mark (the mode + // word moved onto the mark itself) and the not-yet names stack above 760. + expect(ruleBody(v2, '.v2-connector-row__names')).toContain('flex-direction: column'); + // …and the mark SHARES the Tools glyph's rule rather than carrying a second + // rule of its own. The two rows had drifted 16 vs 14, gap 8 vs 6, secondary vs + // tertiary; a rule of its own can always drift again, so the guard counts the + // rules that mention the mark: the shared one and the ≤760 hide, nothing else. + const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, ''); + const markRuleChunks = [...stripComments(v2).matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter(([, selector]) => selector.includes('.v2-connector-row__mark')); + expect(markRuleChunks).toHaveLength(2); + const sharedMarkRule = markRuleChunks.find(([, selector]) => selector.includes('.v2-tools__mode')); + expect(sharedMarkRule?.[2]).toContain('display: inline-flex'); + expect(sharedMarkRule?.[2]).toContain('vertical-align: -2px'); + expect(sharedMarkRule?.[2]).toContain('color: var(--v2-text-tertiary)'); + // Rule 3: the kicker is mono 11; rule 1 (as revised by the TASK-162 gate): the mode word is hidden until 760 and the mark's own 16px rule is GONE — its size now comes from the shared rule above plus the glyph's `size` prop; rule 2: the gear is 32 (44 on the phone). expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('font-size: 11px'); expect(ruleBody(v2, '.v2-connector-row__kicker-mode')).toContain('display: none'); - expect(ruleBody(v2, '.v2-connector-row__mark')).toContain('width: 16px'); + expect(v2).not.toContain('width: 16px; height: 16px; color: var(--v2-text-secondary)'); expect(ruleBody(v2, '.v2-root button.v2-connector-row__action--icon')).toContain('width: 32px'); const phone760 = v2.slice(v2.indexOf('@media (max-width: 760px) {\n .v2-connectors {')); expect(phone760).toContain('.v2-connector-row__kicker-mode { display: inline; }'); + expect(phone760).toContain('.v2-connector-row__names { display: block; }'); + expect(phone760).toContain('.v2-connector-row__name-sep { display: inline; }'); expect(phone760).toContain('.v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px;'); // TASK-140: the aside's Revoke ✕ carries `--secondary` as well as `--icon`, // and `--secondary` sets `min-height: 36px`. Because min-height beats height, diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index ba299b41b..57487b2bb 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -727,8 +727,8 @@ const V2ConnectorsPage: React.FC = () => { const markLabel = relay ? (mirror ? t('connectors.rowMirror', { defaultValue: 'every agent line reaches the channel' }) - : t('connectors.rowAttention', { defaultValue: 'attention · escalations reach the channel' })) - : t('connectors.rowRelayOff', { defaultValue: 'relay off · messages stay in the pod' }); + : t('connectors.rowAttention', { defaultValue: 'escalations reach the channel' })) + : t('connectors.rowRelayOff', { defaultValue: 'messages stay in the pod' }); return { action: (isTelegram || isSlack) ? 'manage' : null, actionLabel: t('connectors.manage', { defaultValue: 'Manage' }), @@ -1064,8 +1064,14 @@ const V2ConnectorsPage: React.FC = () => { {row.line} {row.mark ? ( + // TASK-162 (3): the consequence is the visible words; the mode word + // (mirror / attention / relay off) is what the glyph itself means, + // so it rides title + aria-label rather than being the only thing + // on the line. The glyph rides .v2-tools__mode's 14px rule (see + // v2.css): #1782 says the channel mark follows the Tools glyph. - + + {row.mark.label} ) : ( {row.detail} @@ -1486,7 +1492,14 @@ const V2ConnectorsPage: React.FC = () => { {t('connectors.notYetKicker', { defaultValue: 'not yet' })} diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index e4e58b14f..ffeeefe59 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9890,6 +9890,11 @@ body.modern-ui.v2-canvas { /* Direction A (2026-09-19): the age column is gone — it rides the mono kicker above line 1 — so the row is name · details · one act. */ grid-template-columns: 140px minmax(150px, 1fr) 120px; + /* TASK-162 (2): the channel name and its details met at 0px — the 140px name + track ends exactly where the details track begins, so a name that fills its + track touches the sentence. A 12px gutter on BOTH row grids (here and the + selection button's inner grid below) keeps the two columns apart. */ + column-gap: 12px; align-items: center; min-height: 82px; padding: 16px 18px; @@ -9903,6 +9908,7 @@ body.modern-ui.v2-canvas { display: grid; grid-column: 1 / 3; grid-template-columns: 140px minmax(150px, 1fr); + column-gap: 12px; align-items: center; min-width: 0; padding: 0; @@ -9941,14 +9947,26 @@ body.modern-ui.v2-canvas { .v2-connector-row__detail, .v2-connector-row__when { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } /* Meta wraps to a second line; it never ellipsises (lily-shen, 64026). */ .v2-connector-row__detail { overflow-wrap: anywhere; } +/* TASK-162 (3): line 3 used to be the bare 16px mark, its consequence words + reachable only through the glyph's tooltip — and at ≤760 the mark is hidden, + so line 3 disappeared entirely on a phone. The consequence is now TEXT beside + the mark; the mode word moved onto the mark itself (title + aria-label), and + the glyph itself follows #1782: it takes the Tools glyph's rule below (14px, + gap 6, tertiary, vertical-align -2px) rather than a 16px rule of its own, + because beside a 12/16 mono sentence a 16px mark grows the line box to 18px. */ +/* TASK-162 (2): the not-yet row lists two channels. Above 760 they stack in the + 140px name track; at ≤760 they join with ' · ' (the separator is the only + element that switches, so nothing here depends on generated content). */ +.v2-connector-row__names { display: flex; flex-direction: column; } +.v2-connector-row__name-sep { display: none; } .v2-connector-row__when { padding-right: 16px; } /* Direction A rule 3: `pod · verb age` in mono 11 above line 1, on every row. */ .v2-connector-row__kicker { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; font-weight: 500; line-height: 16px; text-transform: lowercase; overflow-wrap: anywhere; } /* The mode word rides the kicker only where the glyph's tooltip cannot (≤760). */ .v2-connector-row__kicker-mode { display: none; } -/* Direction A rule 1: a category on the row is a 16px mark, the sentence in title + aria-label. */ -.v2-connector-row__mark { display: inline-flex; width: 16px; height: 16px; color: var(--v2-text-secondary); vertical-align: middle; } -.v2-connector-row__mark svg { width: 16px; height: 16px; } +/* Direction A rule 1 (as revised by TASK-162): a category on the row is a mark, + the sentence in title + aria-label — and the mark shares the Tools glyph's rule + (search `.v2-connector-row__mark, .v2-tools__mode`) so the two rows cannot drift. */ .v2-root button.v2-connector-row__action, .v2-root a.v2-connector-row__action { justify-self: end; @@ -9962,8 +9980,15 @@ body.modern-ui.v2-canvas { text-decoration: none; cursor: pointer; } -.v2-root button.v2-connector-row__action:hover:not(:disabled), -.v2-root a.v2-connector-row__action:hover { background: var(--v2-ink-hover); } +/* TASK-162 (1): a hovered secondary action turned INK behind an ink glyph — + `.v2-root button....__action:hover:not(:disabled)` (0,4,1) outranks the + `--secondary:hover` tint (0,3,1), so the gear read as a solid black square + with an invisible glyph. Excluding secondary from the ink rule fixes it + WITHOUT relying on source order: raising the tint's specificity would work + only until someone moves a rule, and the `.v2-root` prefix that every + selector here carries makes order-fragile fixes easy to write by accident. */ +.v2-root button.v2-connector-row__action:hover:not(:disabled):not(.v2-connector-row__action--secondary), +.v2-root a.v2-connector-row__action:hover:not(.v2-connector-row__action--secondary) { background: var(--v2-ink-hover); } .v2-root button.v2-connector-row__action:disabled { cursor: progress; opacity: 0.65; } .v2-root button.v2-connector-row__action--secondary, .v2-root a.v2-connector-row__action--secondary { border-color: var(--v2-border); background: var(--v2-surface); color: var(--v2-ink); } @@ -10095,7 +10120,7 @@ body.modern-ui.v2-canvas { .v2-tools__trail-line { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--v2-border-soft); color: var(--v2-text-muted); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } .v2-tools__trail-line > span:first-child { min-width: 0; overflow-wrap: anywhere; } .v2-tools__trail-when { flex: none; } -.v2-tools__mode, .v2-tools__outcome { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); } +.v2-tools__mode, .v2-tools__outcome, .v2-connector-row__mark { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); } .v2-tools__form { display: grid; gap: 14px; margin-top: 14px; } .v2-tools__field { display: grid; gap: 6px; margin: 0; padding: 0; border: none; min-width: 0; } .v2-tools__field > span:first-child, .v2-tools__field > legend { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; text-transform: lowercase; padding: 0; } @@ -10134,6 +10159,8 @@ body.modern-ui.v2-canvas { .v2-root button.v2-connector-row__selection { grid-row: 1; } .v2-connector-row__kicker-mode { display: inline; } .v2-connector-row__mark { display: none; } + .v2-connector-row__names { display: block; } + .v2-connector-row__name-sep { display: inline; } .v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px; grid-column: 2; grid-row: 1; justify-self: end; align-self: start; } .v2-root button.v2-connector-row__action:not(.v2-connector-row__action--icon), .v2-root a.v2-connector-row__action { display: inline-flex; grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: center; min-height: 44px; margin-top: 8px; } /* The gear sits over the selection button's top-right; keep the kicker out from under it. */