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({ { +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.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index 0f0f86e2d..419f2ef8e 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; +import { keypair, signed } from "../../features/relay/testing"; +import type { ChannelCanvas } from "../../features/channel-templates/capability"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; afterEach(cleanup); @@ -77,3 +79,210 @@ it("does not invent unknown metadata and still exposes diagnostics without a cha await user.click(screen.getByText("Diagnostics")); expect(screen.getByText("Diagnostics content")).toBeVisible(); }); + +it("opens Canvas from its own keyboard-accessible row before setup actions", async () => { + const user = userEvent.setup(); + const openCanvas = vi.fn(); + render( + {}} + openCanvas={openCanvas} + setupTools={} + > + Diagnostics content + , + ); + const canvas = screen.getByRole("button", { name: "Canvas" }); + expect(canvas).toHaveAttribute("aria-haspopup", "dialog"); + expect(canvas).not.toHaveTextContent("Shared notes and plans"); + expect(openCanvas).not.toHaveBeenCalled(); + await user.tab(); + expect(canvas).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(openCanvas).toHaveBeenCalledTimes(1); + expect(openCanvas).toHaveBeenLastCalledWith(canvas); + await user.tab(); + expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus(); + await user.click(canvas); + expect(openCanvas).toHaveBeenCalledTimes(2); +}); + +it("omits Canvas without a writable channel or an opener", () => { + const channel = { id: "alpha", name: "Alpha" }; + const { rerender } = render( + {}}> + Diagnostics content + , + ); + expect( + screen.queryByRole("button", { name: "Canvas" }), + ).not.toBeInTheDocument(); + for (const unavailable of [ + undefined, + { ...channel, readOnly: true as const }, + ]) { + rerender( + {}} + openCanvas={() => {}} + > + Diagnostics content + , + ); + expect( + screen.queryByRole("button", { name: "Canvas" }), + ).not.toBeInTheDocument(); + } +}); + +const head = signed(keypair(), { + kind: 40100, + content: "# Google root-link preview submission deep dive", + tags: [["h", "alpha"]], +}); +function previewFixture() { + return { + available: true, + read: vi.fn().mockResolvedValue(head), + save: vi.fn(), + }; +} +function panel( + canvas: ChannelCanvas, + id = "alpha", + canvasOpen = false, + readOnly?: true, +) { + return ( + {}} + close={() => {}} + > + Diagnostics + + ); +} + +it("shows Canvas as the first line and saved plain text as the secondary preview", async () => { + const canvas = previewFixture(); + const { container } = render(panel(canvas)); + expect(screen.getByText("Loading preview…")).toBeVisible(); + await screen.findByText("Google root-link preview submission deep dive"); + const row = screen.getByRole("button", { name: "Canvas" }); + expect(row).toHaveAccessibleDescription( + "Google root-link preview submission deep dive", + ); + expect(row.querySelector(".buzz-choice-row-label")).toHaveTextContent( + /^Canvas$/, + ); + expect(row.querySelector(".buzz-choice-row-description")).toHaveTextContent( + "Google root-link preview submission deep dive", + ); + expect(canvas.read).toHaveBeenCalledWith("alpha"); + expect(canvas.save).not.toHaveBeenCalled(); + expect(container.querySelector("img, script")).toBeNull(); +}); + +it.each([ + undefined, + { ...head, content: " \n" }, + { ...head, content: "" }, +])("uses neutral empty copy for %j", async (event) => { + const canvas = previewFixture(); + canvas.read.mockResolvedValue(event); + render(panel(canvas)); + expect(await screen.findByText("No content yet")).toBeVisible(); + expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled(); +}); + +it("keeps a failed preview openable and refreshes saved content after closing the editor", async () => { + const canvas = previewFixture(); + canvas.read.mockRejectedValueOnce(new Error("Offline")); + const { rerender } = render(panel(canvas)); + expect( + await screen.findByText("Preview unavailable. Open to retry."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled(); + rerender(panel(canvas, "alpha", true)); + expect(canvas.read).toHaveBeenCalledTimes(1); + rerender(panel(canvas)); + expect( + await screen.findByText("Google root-link preview submission deep dive"), + ).toBeVisible(); + rerender(panel(canvas, "alpha", true)); + canvas.read.mockResolvedValue({ ...head, content: "# Updated document" }); + rerender(panel(canvas)); + expect(await screen.findByText("Updated document")).toBeVisible(); + expect(canvas.save).not.toHaveBeenCalled(); +}); + +it("ignores an old channel's pending preview and hides content immediately on scope or access changes", async () => { + const canvas = previewFixture(); + let release!: (event: typeof head) => void; + canvas.read.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const { rerender } = render(panel(canvas)); + try { + canvas.read.mockResolvedValue({ ...head, content: "# Beta document" }); + rerender(panel(canvas, "beta")); + expect(await screen.findByText("Beta document")).toBeVisible(); + } finally { + await act(async () => release(head)); + } + expect( + screen.queryByText("Google root-link preview submission deep dive"), + ).not.toBeInTheDocument(); + const otherCommunity = previewFixture(); + otherCommunity.read.mockResolvedValue(undefined); + rerender(panel(otherCommunity, "beta")); + expect(screen.queryByText("Beta document")).not.toBeInTheDocument(); + expect(await screen.findByText("No content yet")).toBeVisible(); + rerender(panel(otherCommunity, "beta", false, true)); + expect( + screen.queryByRole("button", { name: /^Canvas/ }), + ).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 e4c4c967a..e2ef638a1 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -1,7 +1,14 @@ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { ChannelCanvas } from "../../features/channel-templates/capability"; +import { canvasPreviewText } from "./canvas-preview"; import type { ChannelSummary } from "../../features/relay/contracts"; import { channelIcon } from "../../features/channels/channel-icon"; -import { XIcon } from "../../shared/design-system/icons/index"; +import { + CaretRightIcon, + FileTextIcon, + XIcon, +} from "../../shared/design-system/icons/index"; +import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; @@ -15,17 +22,62 @@ export function ChannelSettingsPanel({ children, setupTools, details, + openCanvas, + canvas, + canvasOpen = false, }: { channel: ChannelSummary | undefined; close(): void; children: ReactNode; setupTools?: ReactNode; details?: ChannelDetailsCapability; + openCanvas?(trigger: HTMLButtonElement): void; + canvas?: ChannelCanvas; + canvasOpen?: boolean; }) { const closeButton = useRef(null); useEffect(() => { closeButton.current?.focus({ preventScroll: true }); }, []); + const channelId = + channel && !channel.readOnly && openCanvas ? channel.id : undefined; + const [preview, setPreview] = useState<{ + canvas: ChannelCanvas; + channelId: string; + text: string; + failed?: boolean; + }>(); + useEffect(() => { + if (!canvas || !channelId || canvasOpen) return; + setPreview(undefined); + let active = true; + void canvas.read(channelId).then( + (event) => { + if (active) + setPreview({ + canvas, + channelId, + text: canvasPreviewText(event?.content ?? ""), + }); + }, + () => { + if (active) setPreview({ canvas, channelId, text: "", failed: true }); + }, + ); + return () => { + active = false; + }; + }, [canvas, channelId, canvasOpen]); + const currentPreview = + preview?.canvas === canvas && preview?.channelId === channelId + ? preview + : undefined; + const previewText = currentPreview?.text ?? ""; + const previewFallback = currentPreview?.failed + ? "Preview unavailable. Open to retry." + : currentPreview + ? "No content yet" + : "Loading preview…"; const ChannelIcon = channelIcon(channel); return ( )} + {channel && !channel.readOnly && openCanvas && ( + + )} {channel && (channel.channelType === "stream" || channel.channelType === "forum") && diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index a397f111a..cf37bc3da 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); @@ -869,6 +902,9 @@ display: grid; gap: var(--space-4); } +.detailsEditor:empty { + display: none; +} .detailsLabel { display: flex; 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) & ![alt text](https://example.com/image.png)", + "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(""); +}