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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
trap 'kill "$dev_pid" 2>/dev/null || true; cat "$RUNNER_TEMP/writer-dev.log"' EXIT
ready=0
for attempt in {1..60}; do
kill -0 "$dev_pid" 2>/dev/null || exit 1
if curl --fail --silent "$BASE" >/dev/null; then
ready=1
break
Expand All @@ -66,6 +67,27 @@ jobs:
pnpm --filter @mdbase-writer/app exec node scripts/browser-test.mjs || result=1
pnpm --filter @mdbase-writer/app test:browser:reliability || result=1
exit "$result"
- name: Verify private feedback with demo data and intercepted delivery
env:
VITE_WRITER_DEMO: "1"
VITE_MDBASE_FEEDBACK_URL: http://127.0.0.1:8892/v1/feedback
VITE_MDBASE_ENV: lab
run: |
pnpm --filter @mdbase-writer/app build
pnpm --filter @mdbase-writer/app exec vite preview --host 127.0.0.1 --port 8892 --strictPort > "$RUNNER_TEMP/writer-feedback-preview.log" 2>&1 &
preview_pid=$!
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
ready=0
for attempt in {1..30}; do
kill -0 "$preview_pid" 2>/dev/null || exit 1
if curl --max-time 2 --fail --silent http://127.0.0.1:8892/ >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1
pnpm --filter @mdbase-writer/app test:feedback
- name: Upload browser failure screenshots
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-writer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VITE_MDBASE_FEEDBACK_URL: ${{ vars.MDBASE_FEEDBACK_URL }}
VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY: ${{ vars.MDBASE_FEEDBACK_TURNSTILE_SITE_KEY }}
- name: Verify deployed manifest
env:
ORIGIN: ${{ inputs.target == 'production' && 'https://writer.mdbase.dev' || 'https://staging.mdbase-writer.pages.dev' }}
Expand Down
1 change: 1 addition & 0 deletions apps/writer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"preview": "vite preview --host 127.0.0.1",
"test": "pnpm manifest && node --test scripts/*.test.mjs && vitest run",
"test:browser": "node scripts/browser-test.mjs && node scripts/browser-reliability-test.mjs",
"test:feedback": "node scripts/feedback-browser-test.mjs",
"test:browser:reliability": "node scripts/browser-reliability-test.mjs",
"typecheck": "pnpm manifest && tsc -p tsconfig.json",
"deploy:lab": "MDBASE_ENV=lab node scripts/deploy-pages.mjs",
Expand Down
2 changes: 2 additions & 0 deletions apps/writer/scripts/deploy-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const PAGES_FILE_LIMIT = 25 * 1024 * 1024;
await run("pnpm", ["build"], {
...process.env,
MDBASE_WRITER_ORIGIN: deployment.origin,
VITE_MDBASE_ENV: target,
VITE_MDBASE_WRITER_BUILD_ID: commit,
VITE_MDBASE_CONNECT_URL: deployment.connectUrl,
VITE_MDBASE_CONNECT_LOOPBACK_URL: deployment.loopbackUrl,
...(deployment.demo ? { VITE_WRITER_DEMO: "1" } : {}),
Expand Down
70 changes: 70 additions & 0 deletions apps/writer/scripts/feedback-browser-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Demo-data acceptance only. Intercepts every feedback POST; never sends mail.
import assert from "node:assert/strict";
import { chromium } from "playwright";

const origin = process.env.WRITER_FEEDBACK_TEST_ORIGIN ?? "http://127.0.0.1:8892";
if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(origin)) throw new Error("Feedback acceptance requires a loopback preview.");
const browser = await chromium.launch();
try {
for (const width of [1440, 390]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: "reduce" });
const page = await context.newPage();
const posts = [];
await context.route("**/v1/feedback", async (route) => {
posts.push(route.request().postDataJSON());
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});
await page.addInitScript(() => {
window.feedbackCaptures = 0;
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: () => { window.feedbackCaptures++; return Promise.reject(new DOMException("Cancelled", "NotAllowedError")); } },
});
});
await page.goto(`${origin}/?demo`);
await page.locator(".manuscript-row", { hasText: "Small Agents, Large Effects" }).click();
await page.waitForFunction(() => window.writer?.workspace?.getSnapshot().phase === "ready");
// A user-visible compiler failure, not a Markdown/typesetting diagnostic.
await page.evaluate(() => window.writer.workspace.update({ previewProblem: "PRIVATE compiler exception /private/manuscript.md" }));
const trigger = page.locator(".topbar").getByRole("button", { name: "Send feedback", exact: true });
await trigger.click();
const dialog = page.getByRole("dialog", { name: "Send feedback", exact: true });
const message = dialog.getByLabel("What happened?");
assert.equal(await message.evaluate((element) => document.activeElement === element), true);
assert.equal(await dialog.locator("details").evaluate((element) => element.open), false);
assert.equal(await page.evaluate(() => window.feedbackCaptures), 0);
await message.fill("A private sample draft");
await page.screenshot({ path: `/tmp/shared-feedback-writer-form-${width}.png` });
await message.press("Control+k");
await message.press("F8");
assert.equal(await page.locator("dialog[open]").count(), 1);
assert.equal(await message.evaluate((element) => document.activeElement === element), true);
await message.press("Escape");
assert.equal(await dialog.isVisible(), false);
assert.equal(await trigger.evaluate((element) => document.activeElement === element), true);
await trigger.click();
assert.equal(await message.inputValue(), "A private sample draft");
await dialog.getByText("What gets sent", { exact: true }).click();
await dialog.getByLabel("Include technical diagnostics", { exact: true }).check();
const preview = await dialog.locator("pre").last().innerText();
assert.ok(preview.includes("preview_failed"));
assert.ok(!preview.includes("PRIVATE"));
await dialog.getByRole("button", { name: "Send feedback", exact: true }).click();
const thanks = dialog.getByRole("heading", { name: "Thanks for the report." });
await thanks.waitFor();
assert.equal(await thanks.evaluate((element) => document.activeElement === element), true);
assert.equal(posts.length, 1);
const payload = posts[0];
assert.equal(payload.schema_version, 2);
assert.equal(payload.application.product, "mdbase writer");
assert.equal(payload.application.source_view, "manuscript");
assert.ok(payload.diagnostics.events.some((event) => event.code === "preview_failed"));
assert.ok(!JSON.stringify(payload).includes("PRIVATE"));
for (const key of ["context", "screenshot", "reply_email"]) assert.equal(payload[key], undefined);
await page.screenshot({ path: `/tmp/shared-feedback-writer-${width}.png` });
await context.close();
}
console.log("Writer feedback desktop/mobile acceptance passed; all delivery intercepted.");
} finally {
await browser.close();
}
11 changes: 9 additions & 2 deletions apps/writer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { WriterBackend } from "./backend/types.js";
import { createWriterSession, type WriterSession } from "./connect/session.js";
import { appUrls } from "./apps.js";
import { AppSwitcher } from "@mdbase-dev/ui/app-switcher";
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
import { useWriterFeedbackContext } from "./FeedbackRoot.js";
import { ConnectGate } from "./ui/ConnectGate.js";
import { CollectionPicker } from "./ui/CollectionPicker.js";
import { Home } from "./ui/Home.js";
Expand Down Expand Up @@ -65,6 +67,7 @@ function useManuscriptParam(mayLeave: () => Promise<boolean>): [string | null, (
}

export function App() {
const { reportError } = useFeedback();
const [theme, setTheme] = useState<ThemePreference>(() => loadThemePreference());
const [slot, setSlot] = useState<HTMLElement | null>(null);
useEffect(() => saveThemePreference(theme), [theme]);
Expand All @@ -77,24 +80,27 @@ export function App() {
<AppSwitcher current="writer" urls={appUrls} />
<div className="topbar-slot" ref={setSlot} />
<ThemeSelect className="topbar-theme" value={theme} onChange={setTheme} />
<FeedbackButton />
</header>
<ErrorBoundary>{demoRequested ? <DemoRoot /> : <ConnectRoot />}</ErrorBoundary>
<ErrorBoundary onError={() => reportError({ code: "unknown_error" })}>{demoRequested ? <DemoRoot /> : <ConnectRoot />}</ErrorBoundary>
</div>
</TopbarSlot.Provider>
</ThemeChoice.Provider>
);
}

class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
class ErrorBoundary extends Component<{ children: ReactNode; onError(): void }, { error: Error | null }> {
override state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
override componentDidCatch() { this.props.onError(); }
override render() {
if (!this.state.error) return this.props.children;
return (
<main className="gate">
<h1>Something went wrong</h1>
<FeedbackButton topic="problem" />
<p>{this.state.error.message}</p>
<p className="muted">Your saved text is in your collection. Unsent local drafts are backed up in this browser when storage is available. Download a copy before reloading.</p>
<button className="mdbase-button" type="button" onClick={() => {
Expand Down Expand Up @@ -198,6 +204,7 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co
return new Promise<boolean>((finish) => setNavigation({ message: saved.message, finish }));
});
const workspace = useOwned(() => (path ? new ManuscriptWorkspace(backend, path) : null), (w) => void w.dispose(), [backend, path]);
useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName);
currentWorkspace.current = workspace;
(window as unknown as { writer?: unknown }).writer = { backend, workspace };
if (!path) return <Home backend={backend} onOpen={setPath} collectionPicker={collectionPicker} />;
Expand Down
22 changes: 22 additions & 0 deletions apps/writer/src/FeedbackRoot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FeedbackButton } from "@mdbase-dev/ui/feedback";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it, vi } from "vitest";
import { FeedbackRoot } from "./FeedbackRoot.js";

afterEach(() => vi.unstubAllEnvs());

it("hides unconfigured feedback without hiding the Writer shell", () => {
vi.stubEnv("VITE_MDBASE_FEEDBACK_URL", "");
const markup = renderToStaticMarkup(createElement(FeedbackRoot, { children: [createElement("p", { key: "shell" }, "Writer shell"), createElement(FeedbackButton, { key: "feedback" })] }));
expect(markup).toContain("Writer shell");
expect(markup).not.toContain("Send feedback");
});

it("uses the shared entry and excludes unsafe destinations", () => {
for (const endpoint of ["https://feedback.example/v1/feedback", "javascript:alert(1)"]) {
vi.stubEnv("VITE_MDBASE_FEEDBACK_URL", endpoint);
const markup = renderToStaticMarkup(createElement(FeedbackRoot, { children: createElement(FeedbackButton) }));
expect(markup.includes("Send feedback")).toBe(endpoint.startsWith("https:"));
}
});
30 changes: 30 additions & 0 deletions apps/writer/src/FeedbackRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FeedbackProvider, feedbackApplication, resolveFeedbackEndpoint } from "@mdbase-dev/ui/feedback";
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";

interface FeedbackContextValue {
view: "connection" | "manuscripts" | "manuscript";
collectionName?: string;
}
const connection: FeedbackContextValue = { view: "connection" };
const Context = createContext<(value: FeedbackContextValue) => void>(() => undefined);

/** Application context only: no record paths, manuscript titles or session credentials. */
export function FeedbackRoot({ children }: { children: ReactNode }) {
const [context, setContext] = useState(connection);
return <Context.Provider value={setContext}>
<FeedbackProvider
endpoint={resolveFeedbackEndpoint(import.meta.env.VITE_MDBASE_FEEDBACK_URL)}
turnstileSiteKey={import.meta.env.VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY ?? null}
application={feedbackApplication("mdbase writer", context.view, import.meta.env.VITE_MDBASE_WRITER_BUILD_ID, import.meta.env.VITE_MDBASE_ENV ?? (import.meta.env.DEV ? "development" : "production"))}
{...(context.collectionName ? { collectionName: context.collectionName } : {})}
>{children}</FeedbackProvider>
</Context.Provider>;
}

export function useWriterFeedbackContext(view: "manuscripts" | "manuscript", collectionName: string): void {
const setContext = useContext(Context);
useLayoutEffect(() => {
setContext({ view, collectionName });
return () => setContext(connection);
}, [view, collectionName, setContext]);
}
4 changes: 4 additions & 0 deletions apps/writer/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MDBASE_ENV?: string;
readonly VITE_MDBASE_WRITER_BUILD_ID?: string;
readonly VITE_MDBASE_FEEDBACK_URL?: string;
readonly VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY?: string;
readonly VITE_MDBASE_CONNECT_URL?: string;
readonly VITE_MDBASE_CONNECT_LOOPBACK_URL?: string;
readonly VITE_WRITER_DEMO?: string;
Expand Down
8 changes: 8 additions & 0 deletions apps/writer/src/feedback-shell.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* Writer's global label/heading rules must not turn the shared form into an eyebrow. */
.mdbase-feedback-header h2 { text-transform: none; }
.mdbase-feedback-dialog label { color: inherit; }

@media (max-width: 900px) {
.topbar .mdbase-feedback-trigger { min-width: 44px; min-height: 44px; padding: 8px; }
.topbar .mdbase-feedback-trigger > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
}
5 changes: 4 additions & 1 deletion apps/writer/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import "@mdbase-dev/ui/motion.css";
import "@mdbase-dev/ui/controls.css";
import "@mdbase-dev/ui/screens.css";
import "@mdbase-dev/ui/palette.css";
import "@mdbase-dev/ui/feedback.css";
import "./feedback-shell.css";
import "./styles.css";

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import { App } from "./App.js";
import { FeedbackRoot } from "./FeedbackRoot.js";

import { setupPwaInstall } from "./pwa-install.js";
import "./pwa-install.css";
Expand All @@ -22,6 +25,6 @@ const root = document.getElementById("root");
if (!root) throw new Error("Missing #root");
createRoot(root).render(
<StrictMode>
<App />
<FeedbackRoot><App /></FeedbackRoot>
</StrictMode>,
);
30 changes: 25 additions & 5 deletions apps/writer/src/ui/ConnectGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// authorizing a collection, and reviewing the setup the writer needs.
import type { MdbaseApplicationSessionSnapshot } from "@mdbase-dev/connect";
import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens";
import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";

import type { WriterSession } from "../connect/session.js";

Expand All @@ -19,18 +20,37 @@ const RESOURCE_ACTION: Record<string, string> = {
};

function Centered({ title, children }: { title: string; children: ReactNode }) {
return <ConnectLayout app="writer" title={title}>{children}</ConnectLayout>;
return <ConnectLayout app="writer" title={title}>{children}<FeedbackButton /></ConnectLayout>;
}

export function ConnectGate({ session, snapshot }: { session: WriterSession; snapshot: MdbaseApplicationSessionSnapshot }) {
const { reportError } = useFeedback();
const reportedInitialFailure = useRef(false);
useEffect(() => {
if (snapshot.status === "start_failed" && !reportedInitialFailure.current && !/cancel|abort|supersed/u.test(snapshot.problem.code)) {
reportedInitialFailure.current = true;
reportError({ code: "unknown_error" });
}
}, [snapshot, reportError]);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const run = async (action: () => Promise<{ ok: boolean; problem?: { message?: string; code: string } }>) => {
setBusy(true);
setProblem(null);
const outcome = await action();
setBusy(false);
if (!outcome.ok) setProblem(outcome.problem?.message ?? outcome.problem?.code ?? "Something went wrong.");
try {
const outcome = await action();
if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem?.code ?? "")) {
setProblem(outcome.problem?.message ?? outcome.problem?.code ?? "Something went wrong.");
reportError({ code: "unknown_error" });
}
} catch (reason) {
if (!(reason instanceof DOMException && reason.name === "AbortError")) {
setProblem("This action could not be completed. Please try again.");
reportError({ code: "unknown_error" });
}
} finally {
setBusy(false);
}
};
const authorize = (target: "choose" | "selected") =>
run(() => session.authorize(target, { presentation: "popup", timeoutMs: 10 * 60_000 }) as Promise<{ ok: boolean; problem?: { message?: string; code: string } }>);
Expand Down
Loading