From 450d7a09d2eeeb3cc502bdd4393a12cbeaece78c Mon Sep 17 00:00:00 2001
From: Carl
Date: Tue, 29 Sep 2026 13:10:09 -0700
Subject: [PATCH 1/3] feat(channels): surface canvas content in channel
settings
Signed-off-by: Carl
---
.../channels/ChannelCanvasDialog.test.tsx | 56 +++++-
src/bundled/channels/ChannelCanvasDialog.tsx | 5 +-
.../channels/ChannelSettingsPanel.test.tsx | 176 +++++++++++++++++-
src/bundled/channels/ChannelSettingsPanel.tsx | 80 +++++++-
src/bundled/channels/Channels.module.css | 33 ++++
src/bundled/channels/ChannelsPage.tsx | 13 +-
src/bundled/channels/canvas-preview.test.ts | 29 +++
src/bundled/channels/canvas-preview.ts | 32 ++++
8 files changed, 414 insertions(+), 10 deletions(-)
create mode 100644 src/bundled/channels/canvas-preview.test.ts
create mode 100644 src/bundled/channels/canvas-preview.ts
diff --git a/src/bundled/channels/ChannelCanvasDialog.test.tsx b/src/bundled/channels/ChannelCanvasDialog.test.tsx
index 7562530e6..69cee6f81 100644
--- a/src/bundled/channels/ChannelCanvasDialog.test.tsx
+++ b/src/bundled/channels/ChannelCanvasDialog.test.tsx
@@ -8,7 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
-import { StrictMode } from "react";
+import { StrictMode, useRef, useState } from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { keypair, signed } from "../../features/relay/testing";
@@ -163,3 +163,57 @@ it("ignores the stale StrictMode read after the current read enables typing and
release(head);
}
});
+
+it("returns focus to the ingress on close and retains edits without publishing", async () => {
+ const user = userEvent.setup();
+ const canvas = {
+ available: true,
+ read: vi.fn(async () => head),
+ save: vi.fn(async () => head),
+ };
+ function Harness() {
+ const [open, setOpen] = useState(false);
+ const trigger = useRef(null);
+ return (
+ <>
+
+ {open && (
+
+ )}
+ >
+ );
+ }
+ render();
+ const trigger = screen.getByRole("button", { name: "Canvas" });
+ await user.click(trigger);
+ const text = screen.getByRole("textbox", { name: "Canvas Markdown" });
+ await waitFor(() => expect(text).toHaveValue("Saved"));
+ await user.clear(text);
+ await user.type(text, "Local draft");
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(canvas.save).not.toHaveBeenCalled();
+ await user.keyboard("{Enter}");
+ await waitFor(() =>
+ expect(
+ screen.getByRole("textbox", { name: "Canvas Markdown" }),
+ ).toBeEnabled(),
+ );
+ expect(screen.getByRole("textbox", { name: "Canvas Markdown" })).toHaveValue(
+ "Local draft",
+ );
+ await user.click(screen.getByRole("button", { name: "Close Canvas" }));
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(canvas.save).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/channels/ChannelCanvasDialog.tsx b/src/bundled/channels/ChannelCanvasDialog.tsx
index a43e71762..ce7e0f502 100644
--- a/src/bundled/channels/ChannelCanvasDialog.tsx
+++ b/src/bundled/channels/ChannelCanvasDialog.tsx
@@ -3,7 +3,7 @@ import type { ChannelCanvas } from "../../features/channel-templates/capability"
import type { RelayEvent } from "../../features/relay/events";
import { readView, writeView } from "../../shared/view-state";
import { Button } from "../../shared/design-system/ui/Button";
-import { Dialog } from "../../shared/design-system/ui/Dialog";
+import { Dialog, type DialogProps } from "../../shared/design-system/ui/Dialog";
import { Textarea } from "../../shared/design-system/ui/Textarea";
import styles from "./ChannelTemplates.module.css";
@@ -14,12 +14,14 @@ export function ChannelCanvasDialog({
channelId,
open,
onOpenChange,
+ finalFocus,
}: {
canvas: ChannelCanvas;
scope: string;
channelId: string;
open: boolean;
onOpenChange(open: boolean): void;
+ finalFocus?: DialogProps["finalFocus"];
}) {
const key = `canvas-draft-v1:${channelId}`;
const [saved] = useState(() => {
@@ -99,6 +101,7 @@ export function ChannelCanvasDialog({
))}
+ {channel && !channel.readOnly && openCanvas && (
+
+ )}
{setupTools}
Diagnostics
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index a397f111a..5d8e90592 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -637,6 +637,39 @@
.settingsId {
overflow-wrap: anywhere;
}
+.settingsCanvas {
+ width: 100%;
+ padding: var(--space-4);
+ border: 1px solid var(--border-standard);
+ border-radius: var(--radius-card);
+ background: transparent;
+ color: var(--text-standard);
+ font: inherit;
+ text-align: start;
+ cursor: pointer;
+ transition: background-color var(--duration-state) var(--easing-state);
+}
+/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector constrains the shared row description for ellipsis. */
+.settingsCanvas :global(.buzz-choice-row-description) {
+ min-width: 0;
+}
+.settingsCanvasPreview {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.settingsCanvas:hover {
+ background: var(--affordance-panel-hover);
+}
+.settingsCanvas:active {
+ background: var(--affordance-subtle-pressed);
+}
+@media (prefers-reduced-motion: reduce) {
+ .settingsCanvas {
+ transition: none;
+ }
+}
.settingsDiagnostics > summary {
cursor: pointer;
color: var(--text-subtle);
diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index 6fb85158c..9ef1c880c 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -217,6 +217,7 @@ function ChannelWorkspace({
? groupEntry.record.value
: undefined;
const [canvasOpen, setCanvasOpen] = useState(false);
+ const canvasTrigger = useRef(null);
const [kitError, setKitError] = useState("");
useEffect(() => {
void queries.emoji.ensure();
@@ -873,6 +874,7 @@ function ChannelWorkspace({
channelId={current.id}
open={canvasOpen}
onOpenChange={setCanvasOpen}
+ finalFocus={canvasTrigger}
/>
)}
@@ -1092,14 +1094,15 @@ function ChannelWorkspace({
{showingChannelPanel && showingSettings && (
{
+ canvasTrigger.current = trigger;
+ setCanvasOpen(true);
+ }}
setupTools={
current && (
- {!current.readOnly && (
-
- )}
{templateProvider && (
Another line",
+ "A shared document draft Another line",
+ ],
+ [
+ "[Root **link**](https://example.com) & ",
+ "Root link & alt text",
+ ],
+ ["[Label][ref]\n\n[ref]: https://example.com", "Label"],
+ ["\n\n\n\n# Visible", "Visible"],
+ ["
\n\nSafe", "Safe"],
+ ["One\n\ntwo\n\n- three\n- four", "One two three four"],
+ [" \n", ""],
+ ["---\n\n", ""],
+])("projects Markdown as text: %s", (source, expected) => {
+ expect(canvasPreviewText(source)).toBe(expected);
+});
+
+it("bounds the excerpt without splitting a Unicode code point", () => {
+ expect(canvasPreviewText("🐈".repeat(241))).toBe(`${"🐈".repeat(240)}…`);
+});
diff --git a/src/bundled/channels/canvas-preview.ts b/src/bundled/channels/canvas-preview.ts
new file mode 100644
index 000000000..7167f4a26
--- /dev/null
+++ b/src/bundled/channels/canvas-preview.ts
@@ -0,0 +1,32 @@
+import type { Nodes } from "mdast";
+import { fromMarkdown } from "mdast-util-from-markdown";
+import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
+import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
+
+/** Plain text only: no HTML, link destinations, or remote media in the ingress. */
+export function canvasPreviewText(content: string): string {
+ const tree = fromMarkdown(content, {
+ extensions: [gfmStrikethrough()],
+ mdastExtensions: [gfmStrikethroughFromMarkdown()],
+ });
+ const pending: Nodes[] = [...tree.children].reverse();
+ const text: string[] = [];
+ while (pending.length) {
+ const node = pending.pop();
+ if (!node || node.type === "html" || node.type === "definition") continue;
+ if (
+ node.type === "paragraph" ||
+ node.type === "heading" ||
+ node.type === "code" ||
+ node.type === "break"
+ )
+ text.push(" ");
+ if ("children" in node) pending.push(...[...node.children].reverse());
+ else if ("value" in node) text.push(node.value);
+ else if ("alt" in node) text.push(node.alt ?? "");
+ }
+ const preview = Array.from(text.join("").replace(/\s+/g, " ").trim());
+ return preview.length > 240
+ ? `${preview.slice(0, 240).join("")}…`
+ : preview.join("");
+}
From 3251256c91b96c85fdd2701903b1c9706577e350 Mon Sep 17 00:00:00 2001
From: Carl
Date: Tue, 29 Sep 2026 13:25:23 -0700
Subject: [PATCH 2/3] fix(channels): show loading while refreshing canvas
previews
Signed-off-by: Carl
---
.../channels/ChannelSettingsPanel.test.tsx | 35 +++++++++++++++++++
src/bundled/channels/ChannelSettingsPanel.tsx | 1 +
2 files changed, 36 insertions(+)
diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx
index 852e3f936..419f2ef8e 100644
--- a/src/bundled/channels/ChannelSettingsPanel.test.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx
@@ -251,3 +251,38 @@ it("ignores an old channel's pending preview and hides content immediately on sc
).not.toBeInTheDocument();
await waitFor(() => expect(otherCommunity.read).toHaveBeenCalledTimes(1));
});
+
+it("replaces a saved preview with loading and failure states during refresh", async () => {
+ const canvas = previewFixture();
+ const { rerender } = render(panel(canvas));
+ await screen.findByText("Google root-link preview submission deep dive");
+ rerender(panel(canvas, "alpha", true));
+ let reject!: (error: Error) => void;
+ canvas.read.mockReturnValueOnce(
+ new Promise((_, rejectRead) => {
+ reject = rejectRead;
+ }),
+ );
+ try {
+ rerender(panel(canvas));
+ expect(canvas.read).toHaveBeenCalledTimes(2);
+ expect(screen.getByText("Loading preview…")).toBeVisible();
+ expect(
+ screen.queryByText("Google root-link preview submission deep dive"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Canvas" }),
+ ).toHaveAccessibleDescription("Loading preview…");
+ } finally {
+ await act(async () => reject(new Error("Offline")));
+ }
+ expect(screen.getByText("Preview unavailable. Open to retry.")).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Canvas" }),
+ ).toHaveAccessibleDescription("Preview unavailable. Open to retry.");
+ expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled();
+ expect(
+ screen.queryByText("Google root-link preview submission deep dive"),
+ ).not.toBeInTheDocument();
+ expect(canvas.save).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx
index 061896eb6..a733826be 100644
--- a/src/bundled/channels/ChannelSettingsPanel.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.tsx
@@ -49,6 +49,7 @@ export function ChannelSettingsPanel({
}>();
useEffect(() => {
if (!canvas || !channelId || canvasOpen) return;
+ setPreview(undefined);
let active = true;
void canvas.read(channelId).then(
(event) => {
From 7cbcc85795f41e406f884f65b2b1ed650a52eb07 Mon Sep 17 00:00:00 2001
From: Carl
Date: Tue, 29 Sep 2026 14:00:49 -0700
Subject: [PATCH 3/3] fix(channels): order settings content before actions
Signed-off-by: Carl
---
.../channels/ChannelDetailsEditor.test.tsx | 18 +++++++++++++--
src/bundled/channels/ChannelSettingsPanel.tsx | 22 +++++++++----------
src/bundled/channels/Channels.module.css | 3 +++
3 files changed, 30 insertions(+), 13 deletions(-)
diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx
index 92ec2b8e9..556ccffe3 100644
--- a/src/bundled/channels/ChannelDetailsEditor.test.tsx
+++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx
@@ -299,7 +299,7 @@ it("keeps details readable without editing authority or host support", async ()
).toBeVisible();
});
-it("Escape dismisses the select, then the edit dialog, then Settings", async () => {
+it("orders metadata, Canvas, and actions and dismisses each edit layer with Escape", async () => {
const h = harness();
const user = userEvent.setup();
const close = vi.fn();
@@ -307,12 +307,26 @@ it("Escape dismisses the select, then the edit dialog, then Settings", async ()
{}}
+ setupTools={}
close={close}
>
Diagnostics
,
);
- await user.click(await screen.findByRole("button", { name: "Edit details" }));
+ const edit = await screen.findByRole("button", { name: "Edit details" });
+ const canvas = screen.getByRole("button", { name: "Canvas" });
+ expect(
+ screen.getByText(channel.description).compareDocumentPosition(canvas) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ await user.tab();
+ expect(canvas).toHaveFocus();
+ await user.tab();
+ expect(edit).toHaveFocus();
+ await user.tab();
+ expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus();
+ await user.click(edit);
expect(
screen.getByRole("dialog", { name: "Edit channel details" }),
).toBeVisible();
diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx
index a733826be..e2ef638a1 100644
--- a/src/bundled/channels/ChannelSettingsPanel.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.tsx
@@ -163,17 +163,6 @@ export function ChannelSettingsPanel({
>
)}
- {channel &&
- (channel.channelType === "stream" ||
- channel.channelType === "forum") &&
- !channel.readOnly &&
- !channel.cached &&
- !channel.archived &&
- (details?.available ? (
-
- ) : (
- Editing is unavailable on this connection.
- ))}
{channel && !channel.readOnly && openCanvas && (
)}
+ {channel &&
+ (channel.channelType === "stream" ||
+ channel.channelType === "forum") &&
+ !channel.readOnly &&
+ !channel.cached &&
+ !channel.archived &&
+ (details?.available ? (
+
+ ) : (
+ Editing is unavailable on this connection.
+ ))}
{setupTools}
Diagnostics
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index 5d8e90592..cf37bc3da 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -902,6 +902,9 @@
display: grid;
gap: var(--space-4);
}
+.detailsEditor:empty {
+ display: none;
+}
.detailsLabel {
display: flex;