From d0446557e5be02c8de5617a3e8a448f4f76fe32b Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:31:31 -0700
Subject: [PATCH 1/2] fix(v2): the connectors row's hover, name gutter, and
line 3 (TASK-162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three craft fixes ruled from TASK-140 items 3+4 plus the hover bug found
drawing the README frame:
1. A hovered secondary action turned ink behind its ink glyph. The ink
hover rule (0,4,1) outranked the --secondary:hover tint (0,3,1), so a
hovered gear read as a solid square with an invisible glyph. Fixed by
excluding --secondary from the ink rule rather than by raising the
tint's specificity: the .v2-root prefix every selector here carries
makes an order-dependent fix easy to write by accident.
2. The name and details tracks met at 0px (140px name ends where the
details begin), so a name that fills its track touched its sentence.
A 12px gutter on both row grids, and the not-yet channel names are
separate elements - stacked above 760, ' · '-joined at 760 and below.
3. Line 3 was a bare 16px mark whose consequence words lived only in the
glyph's tooltip - and since the mark is hidden at 760 and below, the
line vanished entirely on a phone. The consequence is now visible text
beside the mark; the mode word (mirror / attention / relay off) moved
onto the mark's title and aria-label, where it is what the glyph means.
---
.../v2/__tests__/V2ConnectorsPage.test.tsx | 24 +++++++++++++--
.../v2/__tests__/v2-layout-invariants.test.ts | 29 +++++++++++++++++++
.../src/v2/components/V2ConnectorsPage.tsx | 18 ++++++++++--
frontend/src/v2/v2.css | 29 +++++++++++++++++--
4 files changed, 93 insertions(+), 7 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
index c0f1df3e2..725701116 100644
--- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
@@ -109,11 +109,28 @@ 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).toMatch(/every agent line|messages stay in the pod/);
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: 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 +162,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..75e7970e6 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -1822,6 +1822,33 @@ 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)');
+ // 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__detail--mark')).toContain('display: inline-flex');
+ expect(ruleBody(v2, '.v2-connector-row__names')).toContain('flex-direction: column');
// 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).
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('font-size: 11px');
@@ -1830,6 +1857,8 @@ describe('v2 layout invariants (CSS rule presence)', () => {
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 { flex-direction: row; flex-wrap: wrap; }');
+ 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..700affb01 100644
--- a/frontend/src/v2/components/V2ConnectorsPage.tsx
+++ b/frontend/src/v2/components/V2ConnectorsPage.tsx
@@ -1064,8 +1064,13 @@ 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.
+
+
+ {row.mark.label}
) : (
{row.detail}
@@ -1486,7 +1491,14 @@ const V2ConnectorsPage: React.FC = () => {
- {UNAVAILABLE_PLATFORM_LABELS.join(' · ')}
+
+ {UNAVAILABLE_PLATFORM_LABELS.map((name, index) => (
+
+ {index > 0 && · }
+ {name}
+
+ ))}
+
{t('connectors.notYetKicker', { defaultValue: 'not yet' })}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index e4e58b14f..a4b8e669f 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,6 +9947,16 @@ 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). */
+.v2-connector-row__detail--mark { display: inline-flex; align-items: center; gap: 8px; }
+/* 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; }
@@ -9962,8 +9978,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); }
@@ -10134,6 +10157,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 { flex-direction: row; flex-wrap: wrap; }
+ .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. */
From 112c9af00ef69b96a100e75a4ed270037a9412bc Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 27 Sep 2026 12:00:43 -0700
Subject: [PATCH 2/2] fix(v2): fold the TASK-162 UX-gate changes (TASK-162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Gate: FAIL @ d0446557, four changes, each measured in a browser over that
build at 1440/1200/720/390.
(a) Line 3 repeated the mode word the kicker already carries at <=760
('relay off - messages stay in the pod'). rowAttention and rowRelayOff
lose the prefix; rowMirror was already prefix-free.
(b) connectors.rowAttention / rowMirror / rowRelayOff existed only as
defaultValues, so zh rendered all three in English. Added to en.json and
zh-CN.json (zh: upgraded-copy strings proposed by the gate, for lily-shen's
read). This also moves the copy where it can be guarded: with the key in
the catalog the catalog wins, so reverting a defaultValue changes nothing
rendered — the prefix rule is now asserted against en.json.
(c) At <=760 the names are flex items, and a flex item drops its leading and
trailing spaces, so ' . ' rendered 7.3px narrower than main. The names
wrapper is display: block there, which restores the separator.
(d) The mark kept a 16px rule of its own inside an inline-flex wrapper and had
drifted from the GitHub row below it (16 vs 14, gap 8 vs 6, secondary vs
tertiary; at 1200 a wrapped sentence centred the glyph 8.5px below line 1).
It now shares .v2-tools__mode's rule and renders MarkGlyph size=14, per
#1782. The <=760 hide still wins by source order.
Tests: the invariants that pinned the old shapes moved with them — the
'display: inline-flex' assertion on the deleted wrapper, the 16px rule, and
the <=760 flex row become assertions on the shared selector, on the rule
count (shared + hide, nothing else), and on display: block. Suite 1008/1008,
tsc clean. Five mutations each red exactly the assertion that pins them.
---
frontend/src/i18n/locales/en.json | 3 +++
frontend/src/i18n/locales/zh-CN.json | 3 +++
.../v2/__tests__/V2ConnectorsPage.test.tsx | 22 ++++++++++++++++++-
.../v2/__tests__/v2-layout-invariants.test.ts | 21 +++++++++++++-----
.../src/v2/components/V2ConnectorsPage.tsx | 11 +++++-----
frontend/src/v2/v2.css | 16 ++++++++------
6 files changed, 58 insertions(+), 18 deletions(-)
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 725701116..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 = {
@@ -113,11 +114,30 @@ describe('V2ConnectorsPage', () => {
// 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).toMatch(/every agent line|messages stay in the pod/);
+ 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();
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 75e7970e6..f20f47ed4 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -1846,18 +1846,29 @@ describe('v2 layout invariants (CSS rule presence)', () => {
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__detail--mark')).toContain('display: inline-flex');
+ // 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');
- // 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).
+ // …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 { flex-direction: row; flex-wrap: wrap; }');
+ 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`,
diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx
index 700affb01..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' }),
@@ -1067,9 +1067,10 @@ const V2ConnectorsPage: React.FC = () => {
// 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.
-
-
+ // 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}
) : (
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index a4b8e669f..ffeeefe59 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -9950,8 +9950,10 @@ body.modern-ui.v2-canvas {
/* 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). */
-.v2-connector-row__detail--mark { display: inline-flex; align-items: center; gap: 8px; }
+ 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). */
@@ -9962,9 +9964,9 @@ body.modern-ui.v2-canvas {
.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;
@@ -10118,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; }
@@ -10157,7 +10159,7 @@ 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 { flex-direction: row; flex-wrap: wrap; }
+ .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; }