Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Markdown artifacts render ` ```mermaid ` fenced code blocks as interactive diagr

The code viewer uses Pierre's read-only `File` component from `@pierre/diffs`:

- the same `agent-render` Shiki theme and `--diffs-*` surface variables serve the viewer, the diff renderer, and the artifact editor
- the `agent-render` Shiki theme and `--diffs-*` surface variables serve read-only viewer and diff surfaces; the artifact editor uses Pierre's concrete default themes because its edit runtime consumes raw TextMate colors
- the wrap toggle maps to Pierre's `overflow` option, so it re-renders in place instead of remounting
- `detectCodeLanguage` keys pass through `toPierreLanguage`, which maps any detection tokens that are not Shiki grammar ids

Expand Down
6 changes: 5 additions & 1 deletion src/components/viewer/artifact-body-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useLayoutEffect, useMemo, useRef, type Ref } from "react";
import { useResolvedTheme } from "@/components/theme/use-theme-controller";
import {
CodeView,
DEFAULT_THEMES,
EditProvider,
Editor,
type CodeViewHandle,
Expand Down Expand Up @@ -53,7 +54,10 @@ export function ArtifactBodyEditor({
);
const resolvedTheme = useResolvedTheme();
const codeViewOptions = useMemo(
() => ({ theme: "agent-render", themeType: resolvedTheme }),
// The editor reads raw TextMate colors, bypassing Shiki's CSS-variable
// replacement. The viewer theme's placeholder colors become transparent
// after an edit and it has no selection color. Use concrete editor themes.
() => ({ theme: DEFAULT_THEMES, themeType: resolvedTheme }),
Comment thread
baanish marked this conversation as resolved.
[resolvedTheme],
);

Expand Down
4 changes: 1 addition & 3 deletions src/lib/diff/pierre-edit.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"use client";

// Registers the shared "agent-render" Shiki theme for every Pierre surface.
import "./pierre-theme";

// Same import-seam reasoning as pierre-react.ts, but for the editing surface:
// CodeView/EditProvider plus the Editor runtime stay in the deferred
// artifact-body-editor chunk so the diff viewer never pays for edit machinery.
export { DEFAULT_THEMES } from "@pierre/diffs";
export {
CodeView,
EditProvider,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/diff/pierre-react.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

// Registers the shared "agent-render" Shiki theme for every Pierre surface.
// Registers the shared "agent-render" Shiki theme for read-only Pierre surfaces.
import "./pierre-theme";

/**
Expand Down
79 changes: 79 additions & 0 deletions tests/e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,85 @@ test("edits an open markdown artifact and reshares it as a new link", async ({ p
await expect.poll(() => page.evaluate(() => window.location.hash)).not.toBe(beforeHash);
});

for (const theme of ["light", "dark"] as const) {
test(`keeps edited text and mouse selections visible in ${theme} mode`, async ({ page }) => {
await page.addInitScript((value) => localStorage.setItem("theme", value), theme);
await goToHash(page, `#${encodeEnvelope({
v: 1,
codec: "plain",
artifacts: [{
id: "notes",
kind: "markdown",
filename: "notes.md",
content: "# Release notes\nAlpha release\nBeta checklist",
}],
})}`);
await waitForRendererReady(page, "markdown");
await page.getByRole("button", { name: "Edit", exact: true }).click();
const body = page.getByTestId("artifact-editor-body");
const editor = body.locator("[contenteditable='true']");
const firstLine = editor.locator('[data-line="1"]');
await editor.click();
await page.keyboard.press("ControlOrMeta+Home");
await page.keyboard.press("End");
await page.keyboard.type(" updated");
await expect(firstLine).toHaveText("# Release notes updated");
// Text presence alone missed the CSS-variable theme's transparent token colors.
await expect.poll(() => firstLine.locator("span").evaluateAll((spans) =>
spans.length > 0 && spans.every((span) => /^rgb\(/.test(getComputedStyle(span).color)),
)).toBe(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const dragBodyLines = async () => {
const start = await editor.locator('[data-line="2"]').boundingBox();
const end = await editor.locator('[data-line="3"]').evaluate((line) => {
const range = document.createRange();
range.selectNodeContents(line);
const rect = range.getBoundingClientRect();
return { x: rect.right, y: rect.y + rect.height / 2 };
});
expect(start).not.toBeNull();
await page.mouse.move(start!.x + 1, start!.y + start!.height / 2);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 20 });
await page.mouse.up();
};
// Drag from the beginning of line 2 to the end of line 3 using real pointer input.
await dragBodyLines();
const selection = body.locator("[data-selection-range]").first();
await expect(selection).toBeVisible();
await expect.poll(() => selection.evaluate((element) => {
const color = getComputedStyle(element).backgroundColor;
const host = (element.getRootNode() as ShadowRoot).host;
const background = getComputedStyle(host).getPropertyValue("--diffs-bg").trim();
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
context.fillStyle = background;
context.fillRect(0, 0, 1, 1);
const before = [...context.getImageData(0, 0, 1, 1).data];
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
return before.some((channel, i) => channel !== context.getImageData(0, 0, 1, 1).data[i]);
})).toBe(true);
await page.keyboard.type("Ready to ship");
await expect(editor).toHaveText("# Release notes updatedReady to ship");
// Pierre records replacing a selection as the first inserted character and
// the remaining text, so undo both history entries to restore the selection.
await page.keyboard.press("ControlOrMeta+z");
await page.keyboard.press("ControlOrMeta+z");
await expect(editor).toContainText("Alpha release");
await expect(editor).toContainText("Beta checklist");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("# Release notes updated\n\nReady to ship");
await page.getByRole("button", { name: "plain", exact: true }).click();
await page.getByRole("button", { name: "Generate new link" }).click();
await page.getByRole("button", { name: "Preview here" }).click();
await waitForRendererReady(page, "markdown");
await expect(page.getByRole("heading", { name: "Release notes updated", exact: true })).toBeVisible();
await expect(page.locator(".markdown-article")).toContainText("Ready to ship");
await expect(page.locator(".markdown-article")).not.toContainText("Alpha release");
});
}

test("edits an open code artifact and reshares it as a new link", async ({ page }) => {
const beforeHash = getFragmentHash("Viewer bootstrap");
await goToHash(page, beforeHash);
Expand Down
Loading