From ecb7f35932a1cd2eb6b26d9996f4f84b0920c372 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Wed, 4 Nov 2026 14:06:09 +0000 Subject: [PATCH 1/3] fix(channels): link the description in channel details Channel details showed the description as plain text, so its URLs and #channel references could not be followed. For owners and admins, the Description row's Edit button covered the whole row with an ::after overlay, so clicking anywhere in the text opened Edit channel details. The description now renders through MessageMarkdown, with the session's reference directory and the page's openLink and canOpenLink handlers. URLs, Buzz links and #channel references open their targets for every viewer. The Description row uses a new label-only SettingsDetail variant: the "Description" label and pencil open the editor, and the value no longer does. This follows DESIGN.md's rule that an independent action stays outside a row's primary click target. "No description" and "Not available" stay plain text. Members and Channel ID keep their whole-row actions. docs/channels.md describes the new contract. A Vitest case covers an editor and a member: link roles, onOpenLink targets, the editor staying closed, and the label still opening it. A browser case covers layout hit-testing, which jsdom can't model. Both fail before the fix. The header-menu journey now asserts the label-only Description row. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey --- docs/channels.md | 12 ++-- .../channels/ChannelDetailsEditor.test.tsx | 72 +++++++++++++++++++ src/bundled/channels/ChannelSettingsPanel.tsx | 53 +++++++++++++- src/bundled/channels/Channels.module.css | 16 ++++- src/bundled/channels/ChannelsPage.tsx | 3 + tests/browser/channel-header-menu.spec.mjs | 31 ++++---- tests/browser/channel-settings.spec.mjs | 67 +++++++++++++++++ 7 files changed, 232 insertions(+), 22 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index cc62118723..2b15bc32d6 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -637,14 +637,16 @@ left-to-right fade on hover or keyboard focus (always visible on touch, no anima for reduced motion). The pencil follows the final text line; balanced side padding keeps the text centered and lets long titles wrap without clipping it. Description has a small pencil after its label, -revealed with a short left-to-right fade on whole-row hover or keyboard focus +revealed with a short left-to-right fade on label hover or keyboard focus (always visible on touch, no animation for reduced motion) without shifting layout; -the whole row opens the shared Dialog with one Name/Description/Duration draft. +the label opens the shared Dialog with one Name/Description/Duration draft. +Every viewer sees the description rendered as message Markdown: its URLs, Buzz links +and `#channel` references open their targets, so its value is not part of the Edit target. Opening Description focuses its textarea; the title focuses Name. -Only authorized editors get the interactive rows; other viewers retain plain metadata. +Only authorized editors get the edit actions; other viewers see the same linked description. Members has a right chevron and opens the existing member list. Channel ID has a -small inline copy icon with the same hover/focus/touch behavior; selecting its row -copies the exact ID. Only a successful copy opens a confirmation tooltip, without +small inline copy icon revealed on whole-row hover or keyboard focus (always visible +on touch); selecting its row copies the exact ID. Only a successful copy opens a confirmation tooltip, without growing the row; hovering or focusing does not open a hint or replay old feedback. Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone Edit details button, header-menu Edit details entry and informational Channel type diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx index c154a9adb5..1d9e604557 100644 --- a/src/bundled/channels/ChannelDetailsEditor.test.tsx +++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx @@ -18,6 +18,7 @@ import type { DetailsAttempt, } from "../../features/relay/channel-details"; import type { ChannelDetails } from "../../features/relay/channel-details-protocol"; +import { createRelaySession } from "../../features/relay/session"; afterEach(() => { cleanup(); @@ -438,6 +439,77 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a await user.keyboard("{Escape}"); expect(close).toHaveBeenCalledOnce(); }); +it.each([ + ["an editor", true], + ["a member", false], +])( + "links the description for %s without the editor taking its clicks", + async (_, canEdit) => { + const h = harness(); + h.load.mockResolvedValue({ ...base, canEdit }); + const user = userEvent.setup(); + const owned = createRelaySession(null); + const ops = { id: "ops", name: "ops", channelType: "stream" as const }; + const session = { + ...owned.session, + channels: { + ...owned.session.channels, + list: () => ({ status: "ready" as const, channels: [channel, ops] }), + }, + }; + const open = vi.fn(() => true); + const panel = (description: string | undefined) => ( + {}} + > + Diagnostics + + ); + const { rerender } = render( + panel("R2 runbook: https://example.com/r2-runbook · see #ops"), + ); + await waitFor(() => + expect( + screen.getByRole("complementary", { name: "Channel settings" }), + ).toHaveAttribute("aria-busy", "false"), + ); + const edit = screen.queryByRole("button", { name: "Edit description" }); + expect(!!edit).toBe(canEdit); + const description = screen.getAllByRole("definition")[0]; + if (!description) throw new Error("Missing description"); + await user.click( + within(description).getByRole("link", { + name: "https://example.com/r2-runbook", + }), + ); + expect(open).toHaveBeenLastCalledWith("https://example.com/r2-runbook"); + await user.click(within(description).getByRole("link", { name: "#ops" })); + expect(open).toHaveBeenLastCalledWith("buzz://channel/ops"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + if (edit) { + await user.click(edit); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Description" }), + ).toHaveFocus(), + ); + await user.keyboard("{Escape}"); + } + for (const [value, shown] of [ + ["", "No description"], + [undefined, "Not available"], + ] as const) { + rerender(panel(value)); + expect(screen.getAllByRole("definition")[0]).toHaveTextContent(shown); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + } + }, +); it("only enables Save for normalized, changed, valid fields with connected errors", async () => { const h = harness(); const user = userEvent.setup(); diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index dc9bb1b048..7016c40973 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -17,6 +17,9 @@ import { import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; import { managesCommunity } from "../../features/communities/roster"; import type { CommunityMembership } from "../moderation/membership"; +import type { RelaySession } from "../../features/relay/session"; +import { MessageMarkdown } from "../../features/messages/MessageMarkdown"; +import { useReferenceDirectory } from "../../features/messages/ReferenceText"; import { channelIcon } from "../../features/channels/channel-icon"; import { GearIcon, @@ -48,6 +51,9 @@ export function ChannelSettingsPanel({ lifecycle, membership, openMembers, + session, + canOpenLink, + onOpenLink, }: { channel: ChannelSummary | undefined; scope: string; @@ -59,8 +65,13 @@ export function ChannelSettingsPanel({ lifecycle?: ChannelLifecycleCapability | undefined; membership?: CommunityMembership | undefined; openMembers?(trigger: HTMLButtonElement): void; + /** Links the description like a message; without these it stays plain text. */ + session?: RelaySession | undefined; + canOpenLink?(target: string): boolean; + onOpenLink?(url: string): boolean; }) { const tabbed = !!usePanelTabHost(); + const directory = useReferenceDirectory(session); const header = useRef(null); const panelId = useId(); useEffect(() => { @@ -175,7 +186,34 @@ export function ChannelSettingsPanel({ ? "Not available" : channel.description || "No description" } + content={ + channel.description && + session && + onOpenLink && ( + + ) + } valueClassName={styles.settingsDescription} + labelOnly actionLabel="Edit" loading={edit?.pending} onOpen={ @@ -340,6 +378,7 @@ function AutoJoinSetting({ function SettingsDetail({ label, value, + content, valueClassName, feedback, actionLabel, @@ -349,10 +388,14 @@ function SettingsDetail({ hasPopup = "dialog", disabled = false, loading = false, + labelOnly = false, renderAction = (action) => action, }: { label: string; + /** Plain value, also the action's description. */ value: string; + /** Rendered in place of `value` when present. */ + content?: ReactNode; valueClassName?: string | undefined; feedback?: ReactNode; actionLabel?: string | undefined; @@ -361,6 +404,8 @@ function SettingsDetail({ hasPopup?: "dialog" | false; disabled?: boolean; loading?: boolean | undefined; + /** Only the label opens the action, so the value's own links stay usable. */ + labelOnly?: boolean; renderAction?(action: ReactElement>): ReactNode; onOpen?: ((trigger: HTMLButtonElement) => void) | undefined; }) { @@ -371,7 +416,11 @@ function SettingsDetail({ ? renderAction( + {createPortal(, document.body)} + , + ); + fireEvent.keyDown(screen.getByText("Handled"), { key: "Escape" }); + fireEvent.keyDown(screen.getByText("Portalled"), { key: "Escape" }); + expect(close).not.toHaveBeenCalled(); + fireEvent.keyDown(screen.getByText("Diagnostics"), { key: "Escape" }); + expect(close).toHaveBeenCalledOnce(); +}); + it("does not invent unknown metadata and still exposes diagnostics without a channel", async () => { const user = userEvent.setup(); const { rerender } = render( diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 7016c40973..a0af3f5f9e 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -71,7 +71,6 @@ export function ChannelSettingsPanel({ onOpenLink?(url: string): boolean; }) { const tabbed = !!usePanelTabHost(); - const directory = useReferenceDirectory(session); const header = useRef(null); const panelId = useId(); useEffect(() => { @@ -97,7 +96,12 @@ export function ChannelSettingsPanel({ aria-label="Channel settings" aria-busy={loading} onKeyDown={(event) => { - if (event.key === "Escape") { + // Portalled menus and cards bubble here through React but own their Escape. + if ( + event.key === "Escape" && + !event.defaultPrevented && + event.currentTarget.contains(event.target as Node) + ) { event.stopPropagation(); close(); } @@ -190,23 +194,11 @@ export function ChannelSettingsPanel({ channel.description && session && onOpenLink && ( - @@ -374,6 +366,48 @@ function AutoJoinSetting({ ); } +/** Only a linked description reads the reference directory, so its snapshots + * re-render this value rather than the whole panel. */ +function ChannelDescription({ + channelId, + content, + session, + scope, + onOpenLink, + canOpenLink, +}: { + channelId: string; + content: string; + session: RelaySession; + scope: string; + onOpenLink(url: string): boolean; + canOpenLink?: ((target: string) => boolean) | undefined; +}) { + const directory = useReferenceDirectory(session); + return ( + + ); +} + /** A native button's hit area covers the row without nesting controls or losing list semantics. */ function SettingsDetail({ label, @@ -392,7 +426,7 @@ function SettingsDetail({ renderAction = (action) => action, }: { label: string; - /** Plain value, also the action's description. */ + /** Plain value; also describes a whole-row action. */ value: string; /** Rendered in place of `value` when present. */ content?: ReactNode; @@ -426,7 +460,7 @@ function SettingsDetail({ ? `${actionLabel} ${label.toLowerCase()}` : `View ${label.toLowerCase()}` } - aria-description={value} + aria-description={labelOnly ? undefined : value} aria-haspopup={hasPopup} disabled={loading} aria-busy={loading || undefined} diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index ef66ee3ffd..e80d417add 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -837,7 +837,8 @@ html[data-keyboard-navigation] } .settingsDetailAction[aria-busy="true"] .settingsDetailIcon, .settingsDetailAction:hover .settingsDetailIcon, -.settingsDetailAction:focus-visible .settingsDetailIcon { +.settingsDetailAction:focus-visible .settingsDetailIcon, +.settingsDetails > div:hover .settingsLabelAction .settingsDetailIcon { opacity: 1; transform: translateX(0); } @@ -853,7 +854,8 @@ html[data-keyboard-navigation] .settingsTitleAction:focus-visible .settingsTitlePencil, .settingsDetailIcon, .settingsDetailAction:hover .settingsDetailIcon, - .settingsDetailAction:focus-visible .settingsDetailIcon { + .settingsDetailAction:focus-visible .settingsDetailIcon, + .settingsDetails > div:hover .settingsLabelAction .settingsDetailIcon { transform: none; transition: none; } @@ -867,9 +869,12 @@ html[data-keyboard-navigation] inset: 0; border-radius: inherit; } -/* The value holds its own links, so only the label opens the editor. */ +/* The value holds its own links, so only the label opens the editor. Its + 24px target overhangs the label's line box without moving the row. */ .settingsLabelAction { width: fit-content; + min-height: var(--space-6); + margin-block: calc((1lh - var(--space-6)) / 2); } .settingsDetails > div + div { border-top: 1px solid var(--border-standard); @@ -1109,9 +1114,10 @@ html[data-keyboard-navigation] white-space: pre-wrap; overflow-wrap: anywhere; } -/* The dd already spaces the value from its label. */ +/* The dd spaces the value from its label and sets the values' line height. */ .settingsDescription > :first-child { margin-top: 0; + line-height: inherit; } .detailsEditor { display: grid; diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs index 76729fc5ec..43b1052128 100644 --- a/tests/browser/channel-header-menu.spec.mjs +++ b/tests/browser/channel-header-menu.spec.mjs @@ -193,7 +193,14 @@ test("header actions use the full-size menu and retain pane/dialog focus owners" const background = () => row.evaluate((node) => getComputedStyle(node).backgroundColor); if (wholeRow) await expect.poll(background).not.toBe(resting); - else expect(await background()).toBe(resting); + else { + expect(await background()).toBe(resting); + // Row hover still reveals the pencil; only the label takes the click. + await expect(action.locator("svg").locator("..")).toHaveCSS( + "opacity", + "1", + ); + } await expect(action).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(action).toHaveCSS("padding", "0px"); if (label === "View members") { diff --git a/tests/browser/channel-settings.spec.mjs b/tests/browser/channel-settings.spec.mjs index 4c5ccb7dff..212873e6ce 100644 --- a/tests/browser/channel-settings.spec.mjs +++ b/tests/browser/channel-settings.spec.mjs @@ -184,6 +184,8 @@ test.describe("description links", () => { }); // Permissions have loaded, so a click that reached Edit would open it. await expect(edit).toBeEnabled(); + // The label is now the only pointer target for editing. + expect((await edit.boundingBox()).height).toBeGreaterThanOrEqual(24); const opened = page.waitForEvent("popup"); await panel.getByRole("link", { name: runbook, exact: true }).click(); const external = await opened; From 5befcd5ebee8b308ff89b9e07c2b3dbc610f546d Mon Sep 17 00:00:00 2001 From: mtoohey Date: Wed, 4 Nov 2026 14:45:14 +0000 Subject: [PATCH 3/3] test(channels): prove the description row neither grows nor highlights Two browser assertions from review were weaker than they looked: - The 24px label target check passed even if the negative margin-block stopped applying, because the row would just grow. The description case now checks that the Description value sits at the same offset as Members' (40px inside each row's top border). It doesn't measure the dt, which is 24px because the button's margins collapse through it. Removing the margin fails with 44 vs 39.98. - The "no row highlight" check hovered the padding and snapshotted the background at once. It now hovers the value text, waits for the row-hover pencil reveal as proof the row is hovered, then checks the background. It also hovers the label after a :hover barrier. The label hover catches a highlight rule that loses its label-only exclusion, which hovering the value can't, because the label-only button doesn't cover the value. The value hover catches a plain row-level :hover background. Each assertion fails under its deliberate CSS break in Chromium and WebKit and passes on the current CSS. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey --- tests/browser/channel-header-menu.spec.mjs | 22 ++++++++++++++++------ tests/browser/channel-settings.spec.mjs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs index 43b1052128..b214b086f0 100644 --- a/tests/browser/channel-header-menu.spec.mjs +++ b/tests/browser/channel-header-menu.spec.mjs @@ -188,18 +188,28 @@ test("header actions use the full-size menu and retain pane/dialog focus owners" (node) => getComputedStyle(node).backgroundColor, ); const box = await row.boundingBox(); - // Hit the value/padding below the text control, not just its label. - await page.mouse.move(box.x + box.width / 2, box.y + box.height - 8); const background = () => row.evaluate((node) => getComputedStyle(node).backgroundColor); - if (wholeRow) await expect.poll(background).not.toBe(resting); - else { - expect(await background()).toBe(resting); - // Row hover still reveals the pencil; only the label takes the click. + if (wholeRow) { + // Hit the value/padding below the text control, not just its label. + await page.mouse.move(box.x + box.width / 2, box.y + box.height - 8); + await expect.poll(background).not.toBe(resting); + } else { + // Hover the value text. The revealed pencil proves the row is hovered, + // but only the label takes the click, so the row stays unpainted. + const value = await row.locator("dd").boundingBox(); + await page.mouse.move(value.x + 4, value.y + value.height / 2); await expect(action.locator("svg").locator("..")).toHaveCSS( "opacity", "1", ); + expect(await background()).toBe(resting); + // Hovering the label itself doesn't paint the whole row either. + await action.hover(); + await expect + .poll(() => action.evaluate((node) => node.matches(":hover"))) + .toBe(true); + expect(await background()).toBe(resting); } await expect(action).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(action).toHaveCSS("padding", "0px"); diff --git a/tests/browser/channel-settings.spec.mjs b/tests/browser/channel-settings.spec.mjs index 212873e6ce..26b8b34e80 100644 --- a/tests/browser/channel-settings.spec.mjs +++ b/tests/browser/channel-settings.spec.mjs @@ -186,6 +186,22 @@ test.describe("description links", () => { await expect(edit).toBeEnabled(); // The label is now the only pointer target for editing. expect((await edit.boundingBox()).height).toBeGreaterThanOrEqual(24); + // Its target overhangs the label's line box, so the value sits where + // Members' does. The dt can't show this: the margins collapse through it. + // Measure inside each row's top border, which only later rows have. + const valueOffset = (name) => + panel.getByRole("button", { name, exact: true }).evaluate((button) => { + const row = button.closest("dt").parentElement; + return ( + row.querySelector("dd").getBoundingClientRect().top - + row.getBoundingClientRect().top - + row.clientTop + ); + }); + expect(await valueOffset("Edit description")).toBeCloseTo( + await valueOffset("View members"), + 0, + ); const opened = page.waitForEvent("popup"); await panel.getByRole("link", { name: runbook, exact: true }).click(); const external = await opened;