From 9e64a125868473b8a6b0c38bf5c2dc5980beffc4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:27:02 -0700 Subject: [PATCH 1/2] fix(connectors): a list card is as tall as its rows (TASK-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1547 stretched the channel list to fill the page; #1669 then put Tools under it and the stretch carried on, so at 1200 the channel card was 564px around 262px of rows, and with the GitHub grant open the Tools card was 718px around one 86px row — 630px of empty card. The page scrolls; a card does not hold empty space. - .v2-connectors: drop min-height: calc(100vh - 86px) and its comment. - .v2-connectors__content: drop flex: 1 / min-height: 0 and switch stretch to start; .v2-connectors__rows: drop flex: 1 / min-height: 0 (keep overflow: hidden, it clips the row corners). - The now-dead opt-outs go with them: .v2-tools { flex: none }, and the 1010px and 760px undo rules. - The Slack row names its workspace: a linked Slack stores teamName, never chatTitle, so the row read "Slack · linked to Ops" beside the word Slack. chatTitle still wins when both are set. Guards invert with the decision: v2-layout-invariants asserted the old stretch (and its undo rules); it now asserts no min-height on .v2-connectors, align-items: start with no flex: 1 on the content grid, no flex: 1 on the rows, and that no .v2-tools rule opts out. The Tools opt-out is matched as a regex on the Connectors section rather than through ruleBody, because with the rule gone ruleBody returns '' and the assertion would have passed without reading anything. Three page cases cover the Slack title precedence. --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 42 +++++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 18 ++++++-- .../src/v2/components/V2ConnectorsPage.tsx | 6 ++- frontend/src/v2/v2.css | 15 +++---- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 10f597ee7..9b673c12f 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -146,6 +146,48 @@ describe('V2ConnectorsPage', () => { expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(3); }); + // TASK-156. A linked Slack stores its workspace in `teamName` + // (slackOAuthService) and never in `chatTitle`, so the row read "Slack · + // linked to Ops" beside the word Slack. The workspace name belongs on the + // detail line, not in the name slot — the name stays the platform. + describe('the row names the Slack workspace (TASK-156)', () => { + const slackRow = (config, podId = { _id: 'p2', name: 'Ops' }) => ({ + _id: 'i-slack', + installationId: 'install-slack-u1', + type: 'slack', + status: 'connected', + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + updatedAt: new Date().toISOString(), + config, + podId, + }); + + it('reads the teamName when the link carries no chatTitle', async () => { + mockGets([slackRow({ teamName: 'Acme', liveRelay: false })]); + renderPage(); + + expect(await screen.findByText('Acme · linked to Ops')).toBeInTheDocument(); + expect(screen.queryByText('Slack · linked to Ops')).toBeNull(); + const row = screen.getByText('Acme · linked to Ops').closest('.v2-connector-row'); + expect(row?.querySelector('.v2-connector-row__name')?.textContent).toContain('Slack'); + }); + + it('reads the teamName on the not-linked variant too', async () => { + mockGets([slackRow({ teamName: 'Acme', liveRelay: false }, null)]); + renderPage(); + + expect(await screen.findByText('Acme · not linked to a pod')).toBeInTheDocument(); + }); + + it('keeps chatTitle winning when the link carries both', async () => { + mockGets([slackRow({ chatTitle: 'Rewire crew', teamName: 'Acme', liveRelay: false })]); + renderPage(); + + expect(await screen.findByText('Rewire crew · linked to Ops')).toBeInTheDocument(); + expect(screen.queryByText('Acme · linked to Ops')).toBeNull(); + }); + }); + it('offers a new code from the row and aside when the Telegram code has expired', async () => { mockGets([{ ...connectors[0], config: { connectCode: 'abc123' } }]); axios.post.mockResolvedValue({ data: {} }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index ccacc74bf..458fe82ce 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1798,8 +1798,16 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-connectors__content')).toContain('grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px)'); expect(ruleBody(v2, '.v2-connector-row__details')).toContain('padding-right: 8px'); const connectors = ruleBody(v2, '.v2-connectors'); - expect(connectors).toContain('min-height: calc(100vh - 86px)'); + // TASK-156, inverting #1547: a list card is as tall as its rows. The page + // scrolls; no card holds empty space. ux-lead measured 564px of card around + // 262px of rows at 1200, and a Tools card of 718px around one 86px row with + // the GitHub grant open. + expect(connectors).not.toContain('min-height'); expect(connectors).not.toContain('max-width'); + const connectorsContent = ruleBody(v2, '.v2-connectors__content'); + expect(connectorsContent).toContain('align-items: start'); + expect(connectorsContent).not.toContain('flex: 1'); + expect(ruleBody(v2, '.v2-connectors__rows')).not.toContain('flex: 1'); expect(ruleBody(v2, '.v2-connectors__header p')).not.toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit'); @@ -1833,12 +1841,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-tools__trail-line')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-tools__count strong')).toContain('font-size: 22px'); expect(ruleBody(v2, '.v2-tools__count span')).toContain('font-size: 11px'); - expect(connectorCss).toMatch(/@media \(max-width: 1010px\) \{[\s\S]*?\.v2-connectors \{ min-height: 0; \}/); expect(connectorCss).toMatch(/@media \(max-width: 1010px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\); gap: 24px;/); - expect(connectorCss).toMatch(/@media \(max-width: 1010px\) \{[\s\S]*?\.v2-connectors__rows \{ flex: none; \}/); expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connector-row \{ grid-template-columns: minmax\(0, 1fr\) auto;/); expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\);/); - expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors \{ min-height: 0; gap: 24px; margin: -12px -18px 0;/); + expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors \{ gap: 24px; margin: -12px -18px 0;/); + // The Tools list opt-out is dead once nothing stretches. Matched as a regex on + // the Connectors section, not through ruleBody: with the rule gone ruleBody + // returns '' and would have passed this assertion without reading anything. + expect(connectorCss).not.toMatch(/\.v2-tools \{[^}]*flex: none/); expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \{ grid-column: 1 \/ -1;/); expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \.v2-connector-row__details \{ grid-column: 1 \/ -1; grid-row: 2;/); expect(connectorCss).toMatch(/\.v2-connector-row:not\(.v2-connector-row--dead\):not\(.v2-connector-row--not-enabled\) \.v2-connector-row__detail \{ display: none; \}/); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 85120a2b3..146c14217 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -23,6 +23,10 @@ interface ConnectorGate { interface ConnectorConfig { chatTitle?: string; + // A linked Slack stores its workspace name here (slackOAuthService), never in + // chatTitle — so without this the row read "Slack · linked to …" beside the + // word Slack (TASK-156). + teamName?: string; connectCode?: string; connectCodeExpiresAt?: string; liveRelay?: boolean; @@ -605,7 +609,7 @@ const V2ConnectorsPage: React.FC = () => { const started = ageLine('started', connector.createdAt); const isTelegram = connector.type === 'telegram'; const isSlack = connector.type === 'slack'; - const title = connector.config?.chatTitle || TYPE_LABELS[connector.type] || connector.type; + const title = connector.config?.chatTitle || connector.config?.teamName || TYPE_LABELS[connector.type] || connector.type; if (connector.status === 'error') { return { diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 143aa6d1e..3b327953a 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9686,9 +9686,6 @@ body.modern-ui.v2-canvas { display: flex; flex-direction: column; gap: 28px; - /* The channel list is the page's content card, not a short island in its - shell. The feature body is the inset pane less 28px/42px vertical pad. */ - min-height: calc(100vh - 86px); } .v2-connectors__header h1 { margin: 0; @@ -9706,9 +9703,9 @@ body.modern-ui.v2-canvas { line-height: 20px; } .v2-connectors__loading { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; } -.v2-connectors__content { display: grid; flex: 1; min-height: 0; grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px); gap: 40px; align-items: stretch; } +.v2-connectors__content { display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px); gap: 40px; align-items: start; } .v2-connectors__main { display: flex; min-width: 0; min-height: 0; flex-direction: column; } -.v2-connectors__rows { flex: 1; min-height: 0; overflow: hidden; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); } +.v2-connectors__rows { overflow: hidden; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); } .v2-connector-row { display: grid; @@ -9890,8 +9887,8 @@ body.modern-ui.v2-canvas { /* Tools — the second list (tools plan §6). Rows reuse .v2-connector-row; the aside reuses the connector aside cards. Outcomes are words in muted mono, - never a coloured word. */ -.v2-tools { flex: none; } + never a coloured word. A list card is as tall as its rows (TASK-156): neither + list stretches, so the page scrolls instead of a card holding empty space. */ .v2-tools__head { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } .v2-tools__title { margin: 0; color: var(--v2-ink); font-family: var(--v2-font-display); font-size: 20px; font-weight: 800; letter-spacing: -0.02em; } .v2-tools__count-line { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } @@ -9923,12 +9920,10 @@ body.modern-ui.v2-canvas { .v2-tools__hint { color: var(--v2-text-secondary); font-size: 12px; line-height: 18px; } .v2-tools__hint code { display: inline-block; margin: 0 6px 4px 0; padding: 1px 6px; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); font-family: var(--v2-font-mono); font-size: 12px; white-space: nowrap; } @media (max-width: 1010px) { - .v2-connectors { min-height: 0; } .v2-connectors__content { grid-template-columns: minmax(0, 1fr); gap: 24px; } - .v2-connectors__rows { flex: none; } } @media (max-width: 760px) { - .v2-connectors { min-height: 0; gap: 24px; margin: -12px -18px 0; } + .v2-connectors { gap: 24px; margin: -12px -18px 0; } .v2-connectors__content { grid-template-columns: minmax(0, 1fr); gap: 24px; } .v2-connector-row { grid-template-columns: minmax(0, 1fr) auto; min-height: 0; padding: 14px 12px; } .v2-root button.v2-connector-row__selection { grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; } From 8fc119b3decac10cdd47469a23a00cb44cd6cc02 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:45:02 -0700 Subject: [PATCH 2/2] test(connectors): scan every block for the stretch rules, not just the first (TASK-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's gate passed #1869 at 9e64a125 and named one route it does not cover: `not.toContain('min-height')` reads ruleBody's FIRST line-start `.v2-connectors` match, so a second `.v2-connectors { min-height: … }` written later in the sheet passes every assertion while the browser paints the stretch again — equal specificity, later rule wins. layoutBlocks() instead scans the whole sheet for every declaration block whose last compound carries the class, and the three selectors that hold the decision (`.v2-connectors`, `__content`, `__rows`) are asserted empty of the declarations that would stretch them. A non-vacuity check per selector keeps the filters from passing on an empty scan. Additive only: every assertion sprint-review gated is byte-identical, so their PASS still describes this tree; what is new is a stronger instrument beside it. A rule scoped another way than the last compound (e.g. `.v2-connectors > div`) is still not covered — said in the comment. --- .../v2/__tests__/v2-layout-invariants.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 458fe82ce..2ff956c9a 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1808,6 +1808,29 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(connectorsContent).toContain('align-items: start'); expect(connectorsContent).not.toContain('flex: 1'); expect(ruleBody(v2, '.v2-connectors__rows')).not.toContain('flex: 1'); + // sprint-review's #1869 note: ruleBody reads the FIRST line-start match, so a + // second `.v2-connectors { min-height: … }` written later in the sheet passes + // every assertion above while the browser paints the stretch again — equal + // specificity, later rule wins. Scan EVERY declaration block by selector, so + // position cannot hide one. Matching is on the last compound's class tokens + // (so `.v2-root .v2-connectors` counts, and `.v2-connectors__content` does + // not); a rule scoped some other way is still not covered. + const layoutBlocks = (className: string) => + Array.from(v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)) + .filter(([, sels]) => + sels + .split(',') + .some((sel) => (sel.trim().split(/\s+/).pop() ?? '').split('.').includes(className)), + ) + .map(([, , body]) => body); + // Non-vacuous: each selector's scan must still find the rule it is about. + for (const cls of ['v2-connectors', 'v2-connectors__content', 'v2-connectors__rows']) { + expect(layoutBlocks(cls).length).toBeGreaterThan(0); + } + // A list card is as tall as its rows wherever the rule is written. + expect(layoutBlocks('v2-connectors').filter((b) => b.includes('min-height'))).toEqual([]); + expect(layoutBlocks('v2-connectors__content').filter((b) => b.includes('flex: 1'))).toEqual([]); + expect(layoutBlocks('v2-connectors__rows').filter((b) => b.includes('flex: 1'))).toEqual([]); expect(ruleBody(v2, '.v2-connectors__header p')).not.toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit');