diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91b..fb2d2b85a00 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ testMatch: [ "**/smoke.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", + "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index 1bdb781226f..e15a57fde49 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -88,8 +88,8 @@ function ChangeDescription({ change }: { change: RestartChange }) { const TOOLTIP_CAP = 6; /** - * `tooltip` — renders inside the dark `bg-primary` tooltip; uses - * `text-primary-foreground` variants for contrast there. + * `tooltip` — renders inside the semantic secondary tooltip surface; uses + * `text-secondary-foreground` variants for contrast there. * `inline` — renders inside the amber Runtime banner or other light * surfaces; inherits foreground from the container instead. */ @@ -107,10 +107,10 @@ function DiffList({ cap !== undefined && entries.length > cap ? entries.length - cap : 0; const valueClass = - variant === "tooltip" ? "text-primary-foreground/80" : "text-foreground"; + variant === "tooltip" ? "text-secondary-foreground/80" : "text-foreground"; const overflowClass = variant === "tooltip" - ? "text-primary-foreground/60" + ? "text-secondary-foreground/60" : "text-muted-foreground"; return ( @@ -180,7 +180,7 @@ export function RestartDiffBadge({

Config changed since last start:

-

+

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}

diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 512ee6899fb..326866cf63e 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -143,7 +143,7 @@ export function AddChannelBotTeamsSection({

{team.name}

{team.description ? ( -

+

{team.description}

) : null} @@ -153,15 +153,17 @@ export function AddChannelBotTeamsSection({ inChannelPersonaIds?.has(persona.id) ?? false; return (
- + {persona.displayName} {personaInChannel ? ( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index e1cdee41a76..e792358713f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,6 +34,7 @@ import { resetAvatarPresentations } from "@/features/profile/avatarPresentationS import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; +import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; import { @@ -77,6 +78,7 @@ async function resetCommunityState({ resetLinkPreviewPreparations(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetMessageLinkMetadataCache(); } type CommunityInitResult = diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 5aee675592e..c248b9b88f1 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -21,6 +21,8 @@ const OWNER = "a".repeat(64); const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; const ISSUE_ID = "b".repeat(64); const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`; +const PR_ID = "c".repeat(64); +const PR_HREF = `buzz://pr?id=${PR_ID}&owner=${OWNER}&d=buzz-world`; test("resolves a composer preview and canonicalizes the underlying href", () => { assert.deepEqual( @@ -205,7 +207,9 @@ test("composer node uses the sent-message chip presentation", () => { assert.match(rendered[1].class, /inline-chip-with-icon/); assert.match(rendered[1].class, /inline-chip-icon-message/); assert.equal(rendered[1]["data-buzz-link"], ""); - assert.equal(rendered[2], "general · root-eve"); + // Channel label only — no event hash, so the chip does not change width when + // the draft is sent and the rendered chip resolves its metadata. + assert.equal(rendered[2], "general"); }); test("composer node renders channel and entity chip presentations", () => { @@ -233,7 +237,14 @@ test("composer node renders channel and entity chip presentations", () => { const issue = render(ISSUE_HREF); assert.equal(issue[1]["data-buzz-link-kind"], "issue"); assert.match(issue[1].class, /inline-chip-icon-issue/); - assert.equal(issue[2], "buzz-world · bbbbbbbb"); + // Repository name only — the rendered chip never widens into the issue + // title, so the composer must not widen into the event hash either. + assert.equal(issue[2], "buzz-world"); + + const pullRequest = render(PR_HREF); + assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr"); + assert.match(pullRequest[1].class, /inline-chip-icon-pr/); + assert.equal(pullRequest[2], "buzz-world · cccccccc"); }); test("markdown rendering stores identity in attributes, not visible id text", () => { diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index a01109e010d..c3182a3038f 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -232,7 +232,9 @@ function composerLinkPresentation( "data-message-link": "", }, icon: "message", - label: `${resolvedChannelName} · ${message.value.messageId.slice(0, 8)}`, + // Matches the rendered inline message chip, which never shows the event + // hash — the label must not change when the draft is sent. + label: resolvedChannelName, }; } @@ -276,10 +278,12 @@ function composerLinkPresentation( channelName: "", dataAttributes: { "data-buzz-link-kind": entity.value.type }, icon: entity.value.type, + // Only pull requests carry their short id inline; issue chips match the + // rendered message chip, which shows the repository name alone. label: - entity.value.type === "repo" || entity.value.type === "project" - ? entity.value.dtag - : `${entity.value.dtag} · ${shortId}`, + entity.value.type === "pr" + ? `${entity.value.dtag} · ${shortId}` + : entity.value.dtag, }; } diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs new file mode 100644 index 00000000000..5b44c799c8f --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { summarizeMessageLinkContent } from "./messageLinkMetadata.ts"; + +test("summarizeMessageLinkContent projects markdown to bounded plain text", () => { + assert.equal( + summarizeMessageLinkContent( + "**Hello** [team](https://example.com)\n\n![secret](https://example.com/a.png) ||hidden||", + ), + "Hello team", + ); + assert.equal( + summarizeMessageLinkContent("https://example.com"), + "No message text", + ); +}); + +test("summarizeMessageLinkContent truncates on grapheme-safe character boundaries", () => { + const result = summarizeMessageLinkContent(`Lead ${"🦄".repeat(200)}`); + assert.ok(Array.from(result).length <= 160); + assert.ok(result.endsWith("…")); + assert.ok(!result.includes("\ud83e") || result.includes("🦄")); +}); diff --git a/desktop/src/features/messages/lib/messageLinkMetadata.ts b/desktop/src/features/messages/lib/messageLinkMetadata.ts new file mode 100644 index 00000000000..848b207d693 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkMetadata.ts @@ -0,0 +1,29 @@ +const MESSAGE_LINK_SNIPPET_MAX_LENGTH = 160; + +/** Build a compact, non-recursive plain-text preview for a linked message. */ +export function summarizeMessageLinkContent(content: string): string { + const normalized = Array.from(content, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + ? " " + : character; + }) + .join("") + .replace(/\|\|[^|]*(?:\|(?!\|)[^|]*)*\|\|/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return "No message text"; + + const characters = Array.from(normalized); + if (characters.length <= MESSAGE_LINK_SNIPPET_MAX_LENGTH) return normalized; + const clipped = characters + .slice(0, MESSAGE_LINK_SNIPPET_MAX_LENGTH - 1) + .join(""); + const lastSpace = clipped.lastIndexOf(" "); + const snippet = lastSpace > 96 ? clipped.slice(0, lastSpace) : clipped; + return `${snippet.trimEnd()}…`; +} diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs index 1d7fdeacb3d..99fa0ed510a 100644 --- a/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs +++ b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs @@ -17,6 +17,7 @@ test("turns every bare Buzz entity permalink family into a chip node", () => { const id = "cd".repeat(32); const links = [ `buzz://repo?owner=${owner}&d=buzz`, + `buzz://project?owner=${owner}&d=onboarding`, `buzz://pr?id=${id}&owner=${owner}&d=buzz`, `buzz://issue?id=${id}&owner=${owner}&d=buzz`, ]; diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.ts b/desktop/src/features/messages/lib/remarkEntityLinks.ts index 41cf4af20b5..85ba43f7744 100644 --- a/desktop/src/features/messages/lib/remarkEntityLinks.ts +++ b/desktop/src/features/messages/lib/remarkEntityLinks.ts @@ -1,7 +1,7 @@ -/** Detect bare `buzz://pr|issue|repo?…` URLs in markdown text nodes. */ +/** Detect bare `buzz://pr|issue|repo|project?…` URLs in markdown text nodes. */ import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; -const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo)\?[^\s<>"')\]]+/g; +const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"')\]]+/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; export default function remarkEntityLinks() { diff --git a/desktop/src/features/messages/ui/SentFromThreadLine.tsx b/desktop/src/features/messages/ui/SentFromThreadLine.tsx index 75e8d1ae1f1..a78d7df2cb4 100644 --- a/desktop/src/features/messages/ui/SentFromThreadLine.tsx +++ b/desktop/src/features/messages/ui/SentFromThreadLine.tsx @@ -44,6 +44,9 @@ export function SentFromThreadLine({ channels={channels} interactive link={link} + onOpenChannel={(targetChannelId) => { + void goChannel(targetChannelId); + }} onOpenMessageLink={onOpenMessageLink} threadExcerpt={reference.rootExcerpt} variant="sent-from-thread" diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx index 81262928947..21a70d6dcb0 100644 --- a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -60,7 +60,7 @@ export function ProjectAuthorIdentity({ /> {label} - + {roleLabel} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 8eb4853376b..067e67dd390 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -330,7 +330,7 @@ function RepositoryUnavailableIndicator({

{label}

-

{description}

+

{description}

); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 8b210f6ab35..6e71de2181a 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -381,6 +381,13 @@ const entityMetadataLoader = createMetadataLoader({ fetcher: fetchBuzzEntityMetadata, }); +/** Share deduplicated relay-native entity metadata across cards and inline tooltips. */ +export async function loadBuzzEntityMetadata( + href: string, +): Promise { + return (await entityMetadataLoader.load(href)).metadata; +} + /** Clear ephemeral metadata when the active relay/community changes. */ export function resetLinkPreviewMetadataCache(): void { metadataLoader.reset(); diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 7d0a238c179..f39de74c522 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -87,6 +87,25 @@ color: hsl(var(--primary)); } +.message-markdown .mention-chip.wrapping-inline-chip { + display: inline; + line-height: calc( + 1em + + var(--inline-chip-padding-block-start) + + var(--inline-chip-padding-block-end) + + 0.25rem + ); + overflow-wrap: anywhere; + text-align: left; + white-space: normal; +} + +.message-markdown .mention-chip.buzz-link-unavailable, +.message-markdown .mention-chip.buzz-link-unavailable:hover { + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground) / 0.7); +} + .message-markdown .mention-chip.inbox-channel-chip { background: hsl(var(--muted)); color: hsl(var(--muted-foreground) / 0.82); @@ -137,6 +156,7 @@ .message-markdown.inbox-preview-markdown .inline-code-chip, .message-markdown.inbox-preview-markdown :not(pre) > code { display: inline; + line-height: 1; overflow: visible; overflow-wrap: normal; text-overflow: clip; @@ -166,6 +186,30 @@ transform: translateY(-50%); } +.message-markdown .wrapping-inline-chip.inline-chip-with-icon { + padding-left: var(--inline-chip-padding-inline); +} + +.message-markdown .wrapping-inline-chip.inline-chip-with-icon::before { + display: none; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon { + position: relative; + display: inline-flex; + align-items: center; + padding-left: calc( + var(--inline-chip-icon-size) + + var(--inline-chip-icon-gap) + ); + white-space: nowrap; +} + +.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before { + display: block; + left: 0; +} + .message-markdown .inline-chip-icon-message::before { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") diff --git a/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs b/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs new file mode 100644 index 00000000000..7fdc1d6097e --- /dev/null +++ b/desktop/src/shared/styles/globals/tooltipSemantics.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const utilitiesCss = readFileSync( + new URL("./utilities.css", import.meta.url), + "utf8", +); +const huddleTooltipRule = utilitiesCss.match( + /\.buzz-huddle-tooltip\s*\{([\s\S]*?)\n\s*\}/, +)?.[1]; + +test("huddle tooltips consume their dedicated semantic tokens", () => { + assert.ok(huddleTooltipRule, "missing .buzz-huddle-tooltip rule"); + assert.match( + huddleTooltipRule, + /background:\s*hsl\(\s*var\(--huddle-tooltip-surface,/, + ); + assert.match( + huddleTooltipRule, + /color:\s*hsl\(\s*var\(\s*--huddle-tooltip-foreground,/, + ); + assert.doesNotMatch( + huddleTooltipRule, + /--(?:primary|secondary)(?:-foreground)?/, + ); +}); diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index a9da737bcef..0d5e0f453f4 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -38,16 +38,9 @@ } .buzz-huddle-tooltip { - --primary: var( - --huddle-tooltip-surface, - var(--huddle-control-surface, 0 0% 20%) - ); - --primary-foreground: var( - --huddle-tooltip-foreground, - var(--huddle-control-foreground, 0 0% 98%) + background: hsl( + var(--huddle-tooltip-surface, var(--huddle-control-surface, 0 0% 20%)) ); - - background: hsl(var(--primary)); border: 1px solid hsl( var( @@ -57,7 +50,12 @@ 0.72 ); box-shadow: 0 10px 22px rgb(0 0 0 / 35%); - color: hsl(var(--primary-foreground)); + color: hsl( + var( + --huddle-tooltip-foreground, + var(--huddle-control-foreground, 0 0% 98%) + ) + ); } .buzz-huddle-drawer .buzz-huddle-control-button, diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 5e829f62434..d35cf790634 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // These are copied here to avoid importing from .ts files that depend on // React (which isn't resolvable outside the bundler). Same pattern as @@ -656,6 +657,13 @@ test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => { assert.doesNotMatch(html, /href=""/); }); +test("buzzDeepLinkUrlTransform: preserves buzz://project autolink href", () => { + const projectLink = `buzz://project?owner=${OWNER_HEX}&d=onboarding`; + const html = renderMarkdown(`<${projectLink}>`); + assert.match(html, /href="buzz:\/\/project\?/); + assert.doesNotMatch(html, /href=""/); +}); + test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => { // Strict parser rejects unknown params — transform falls back to default sanitizer. const html = renderMarkdown( @@ -1080,32 +1088,123 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { }); const html = renderToStaticMarkup( React.createElement( - MarkdownRuntimeContext.Provider, - { - value: { - channels: [{ id: channelId, name: "engineering" }], - onOpenChannel: () => {}, - onOpenEntityLink: () => {}, - onOpenMessageLink: () => {}, - relayOrigin: null, + QueryClientProvider, + { client: new QueryClient() }, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, }, - }, - markdown, + markdown, + ), ), ); + const visibleText = html.replace(/<[^>]+>/g, ""); assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 6); - assert.equal((html.match(/inline-chip-icon-message/g) ?? []).length, 2); - assert.equal((html.match(/>engineering · c3b589faengineering]*>ebuzz-world · c3b589fabuzz-world { + const renderEntityChip = (href) => + renderToStaticMarkup( + renderEntityLinkAnchor({ + children: null, + href, + onOpenEntityLink: () => {}, + relayOrigin: null, + }), + ); + + const issueHtml = renderEntityChip( + `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + ); + const issueText = issueHtml.replace(/<[^>]+>/g, ""); + assert.equal(issueText, "buzz-world"); + assert.doesNotMatch(issueText, /c3b589fa/); + assert.doesNotMatch(issueText, /·/); + // Identity, icon, and navigation affordances survive the shorter label. + assert.match(issueHtml, /data-buzz-link-kind="issue"/); + assert.match(issueHtml, /inline-chip-icon-issue/); + assert.match( + issueHtml, + /aria-label="Open issue c3b589fa in repository buzz-world"/, + ); + + // Pull-request chips are untouched by the issue-only policy. + const pullRequestText = renderEntityChip( + `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + ).replace(/<[^>]+>/g, ""); + assert.equal(pullRequestText, "buzz-world · c3b589fa"); +}); + +test("inline message chips omit fetched metadata and the event hash", () => { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: `buzz://message?channel=${channelId}&id=${EVENT_HEX}`, + variant: "inline-message-chip-metadata-test", + }); + // A readable channel is the case that used to swap the chip label from the + // truncated event hash to the fetched snippet once metadata resolved. + const html = renderToStaticMarkup( + React.createElement( + QueryClientProvider, + { client: new QueryClient() }, + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [ + { + id: channelId, + isMember: true, + name: "engineering", + visibility: "open", + }, + ], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ), + ); + + const visibleText = html.replace(/<[^>]+>/g, ""); + assert.equal((html.match(/data-message-link=""/g) ?? []).length, 1); + assert.equal(visibleText.trim(), "engineering"); + assert.doesNotMatch(visibleText, /c3b589fa/); + assert.doesNotMatch(visibleText, /·/); }); test("authored Buzz permalink labels remain ordinary links", () => { @@ -1174,8 +1273,11 @@ test("bare Buzz permalinks shorten unavailable channel identifiers", () => { ), ); - assert.match(html, />580ca78b · c3b589fa580ca78b]*>5<\/span>80ca78b/g) ?? []) + .length, + 2, + ); assert.doesNotMatch(html, /#channel/); }); @@ -1204,7 +1306,9 @@ test("channel references replace the authored hash with the channel icon", () => ); assert.match(html, /inline-chip-icon-channel/); - assert.match(html, />engineering]*>e]+>/g, ""), /engineering/); assert.doesNotMatch(html, />#engineering { }); const html = renderToStaticMarkup(el); assert.match(html, /data-buzz-link=""/); - assert.match(html, /