diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 390f3eb..aba7353 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,26 @@ jobs:
run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium
- name: Verify PDF gestures and mobile highlight persistence
run: pnpm --filter @mdbase-reader/app test:pdf-touch
+ - name: Verify private feedback with sample data and intercepted delivery
+ env:
+ VITE_MDBASE_FEEDBACK_URL: http://127.0.0.1:8891/v1/feedback
+ VITE_MDBASE_ENV: lab
+ run: |
+ pnpm --filter @mdbase-reader/app build
+ pnpm --filter @mdbase-reader/app exec vite preview --host 127.0.0.1 --port 8891 --strictPort > "$RUNNER_TEMP/reader-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:8891/ >/dev/null; then
+ ready=1
+ break
+ fi
+ sleep 1
+ done
+ test "$ready" -eq 1
+ pnpm --filter @mdbase-reader/app test:feedback
# Generated manifests are committed for production; building for it must leave the tree clean.
- run: pnpm build
env:
diff --git a/.github/workflows/deploy-reader.yml b/.github/workflows/deploy-reader.yml
index daf4d79..410adec 100644
--- a/.github/workflows/deploy-reader.yml
+++ b/.github/workflows/deploy-reader.yml
@@ -59,6 +59,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://reader.mdbase.dev' || 'https://staging.mdbase-reader.pages.dev' }}
diff --git a/apps/reader/package.json b/apps/reader/package.json
index 5431452..4b55e54 100644
--- a/apps/reader/package.json
+++ b/apps/reader/package.json
@@ -15,6 +15,7 @@
"preview": "vite preview --host 127.0.0.1",
"test": "pnpm manifest && pnpm manifest:verify && node --test scripts/*.test.mjs && vitest run src capture --maxWorkers=2 --testTimeout=15000",
"test:browser": "node scripts/audit-reader.mjs",
+ "test:feedback": "node scripts/feedback-browser-test.mjs",
"test:pdf-touch": "node scripts/run-pdf-touch-audits.mjs",
"typecheck": "tsc -p tsconfig.json",
"test:a11y": "node scripts/audit-accessibility.mjs"
diff --git a/apps/reader/scripts/feedback-browser-test.mjs b/apps/reader/scripts/feedback-browser-test.mjs
new file mode 100644
index 0000000..53b272e
--- /dev/null
+++ b/apps/reader/scripts/feedback-browser-test.mjs
@@ -0,0 +1,69 @@
+// Sample-data acceptance only. Intercepts every feedback POST; never sends mail.
+/* global window */
+import assert from "node:assert/strict";
+import { chromium, expect } from "@playwright/test";
+
+const origin = process.env.READER_FEEDBACK_TEST_ORIGIN ?? "http://127.0.0.1:8891";
+if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/u.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 } });
+ 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}/?preview`);
+ const trigger = page
+ .locator(".reader-header")
+ .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?");
+ await expect(message).toBeFocused();
+ await expect(dialog.locator("details")).not.toHaveAttribute("open");
+ assert.equal(await page.evaluate(() => window.feedbackCaptures), 0);
+ await message.fill("A private sample draft");
+ await page.screenshot({ path: `/tmp/shared-feedback-reader-form-${width}.png` });
+ await message.press("Control+k");
+ assert.equal(await page.locator("dialog[open]").count(), 1);
+ await message.press("Escape");
+ await expect(dialog).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+ await trigger.click();
+ await expect(message).toHaveValue("A private sample draft");
+ await dialog.getByRole("button", { name: "Attach screenshot", exact: true }).click();
+ await expect(dialog.getByRole("status")).toContainText("No screenshot taken");
+ assert.equal(await page.evaluate(() => window.feedbackCaptures), 1);
+ await dialog.getByRole("button", { name: "Send feedback", exact: true }).click();
+ await expect(dialog.getByRole("heading", { name: "Thanks for the report." })).toBeFocused();
+ assert.equal(posts.length, 1);
+ const payload = posts[0];
+ assert.equal(payload.schema_version, 2);
+ assert.equal(payload.application.product, "mdbase reader");
+ assert.ok(["library", "document"].includes(payload.application.source_view));
+ for (const key of ["context", "diagnostics", "screenshot", "reply_email"])
+ assert.equal(payload[key], undefined);
+ await page.screenshot({ path: `/tmp/shared-feedback-reader-${width}.png` });
+ await context.close();
+ }
+ console.log("Reader feedback desktop/mobile acceptance passed; all delivery intercepted.");
+} finally {
+ await browser.close();
+}
diff --git a/apps/reader/src/ConnectReader.tsx b/apps/reader/src/ConnectReader.tsx
index 593da12..da85c5a 100644
--- a/apps/reader/src/ConnectReader.tsx
+++ b/apps/reader/src/ConnectReader.tsx
@@ -1,3 +1,4 @@
+import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens";
import { connectProblemMessage, type ReaderConnectSnapshot } from "@mdbase-reader/connect";
import { createReaderRuntimeServices, createWebPlatform } from "@mdbase-reader/platform";
@@ -54,14 +55,23 @@ async function startSession(): Promise(null);
const start = async (): Promise => {
setError(null);
try {
- setError(connectProblemMessage(await startSession()));
+ const outcome = await startSession();
+ setError(connectProblemMessage(outcome));
+ if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
+ reportError({ code: "unknown_error" });
+ }
} catch (reason) {
+ if (reason instanceof DOMException && reason.name === "AbortError") {
+ return;
+ }
+ reportError({ code: "source_open_failed" });
setError(readerErrorMessage(reason, "Reader could not open this collection."));
}
};
@@ -72,17 +82,21 @@ export function ConnectReader(): JSX.Element {
.then((outcome) => {
if (active) {
setError(connectProblemMessage(outcome));
+ if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
+ reportError({ code: "unknown_error" });
+ }
}
})
.catch((reason: unknown) => {
- if (active) {
+ if (active && !(reason instanceof DOMException && reason.name === "AbortError")) {
+ reportError({ code: "source_open_failed" });
setError(readerErrorMessage(reason, "Reader could not open this collection."));
}
});
return () => {
active = false;
};
- }, []);
+ }, [reportError]);
useEffect(() => {
// Overlap workspace download with live collection checks, not the first query.
@@ -197,17 +211,20 @@ function OpenedReader({ collectionId }: { readonly collectionId: string }): JSX.
);
}
+interface ConnectionScreenProps {
+ readonly session: Exclude;
+ readonly error: string | null;
+ readonly onError: (message: string | null) => void;
+ readonly onRetry: () => void;
+}
+
function ConnectionScreen({
session,
error,
onError,
onRetry,
-}: {
- readonly session: Exclude;
- readonly error: string | null;
- readonly onError: (message: string | null) => void;
- readonly onRetry: () => void;
-}): JSX.Element {
+}: ConnectionScreenProps): JSX.Element {
+ const { reportError } = useFeedback();
const [working, setWorking] = useState(false);
const selectedCollectionId = "collectionId" in session ? session.collectionId : null;
// Some failures arrive only as the session's status, not as a step's error.
@@ -219,7 +236,14 @@ function ConnectionScreen({
try {
const outcome = await readerSession.authorize(target);
onError(connectProblemMessage(outcome));
+ if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
+ reportError({ code: "unknown_error" });
+ }
} catch (reason) {
+ if (reason instanceof DOMException && reason.name === "AbortError") {
+ return;
+ }
+ reportError({ code: "unknown_error" });
onError(readerErrorMessage(reason, "Reader could not review application access."));
} finally {
setWorking(false);
@@ -231,7 +255,14 @@ function ConnectionScreen({
try {
const outcome = await readerSession.applyCollectionSetup();
onError(connectProblemMessage(outcome));
+ if (!outcome.ok && !/cancel|abort|supersed/u.test(outcome.problem.code)) {
+ reportError({ code: "unknown_error" });
+ }
} catch (reason) {
+ if (reason instanceof DOMException && reason.name === "AbortError") {
+ return;
+ }
+ reportError({ code: "unknown_error" });
onError(readerErrorMessage(reason, "Reader could not apply the reviewed setup."));
} finally {
setWorking(false);
@@ -304,6 +335,7 @@ function ConnectionScreen({
The managed service requires an HTTPS Reader origin.
) : null}
+
);
}
diff --git a/apps/reader/src/ConnectedDocument.tsx b/apps/reader/src/ConnectedDocument.tsx
index bf84d66..27e551c 100644
--- a/apps/reader/src/ConnectedDocument.tsx
+++ b/apps/reader/src/ConnectedDocument.tsx
@@ -1,3 +1,4 @@
+import { useFeedback } from "@mdbase-dev/ui/feedback";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type JSX } from "react";
import { isEpub, isHtml, isPdf } from "./document-media.js";
@@ -63,6 +64,7 @@ function OpenConnectedDocument({
source,
onSurfaceChange,
}: ConnectedDocumentProps & { readonly descriptor: DocumentDescriptor }): JSX.Element {
+ const { reportError } = useFeedback();
const [state, setState] = useState({ status: "opening" });
const [attempt, setAttempt] = useState(0);
const stableDescriptor = useMemo(
@@ -99,7 +101,8 @@ function OpenConnectedDocument({
}
})
.catch((reason: unknown) => {
- if (active) {
+ if (active && !controller.signal.aborted) {
+ reportError({ code: "source_open_failed" });
setState({ status: "error", message: message(reason) });
}
});
@@ -110,7 +113,7 @@ function OpenConnectedDocument({
void opened.close();
}
};
- }, [attempt, repository, source.collectionId, stableDescriptor]);
+ }, [attempt, repository, source.collectionId, stableDescriptor, reportError]);
useEffect(() => () => onSurfaceChange(null), [onSurfaceChange]);
const handle = state.status === "open" ? state.handle : null;
diff --git a/apps/reader/src/DocumentRendererStage.tsx b/apps/reader/src/DocumentRendererStage.tsx
index 09cb4ea..8c5a670 100644
--- a/apps/reader/src/DocumentRendererStage.tsx
+++ b/apps/reader/src/DocumentRendererStage.tsx
@@ -1,4 +1,5 @@
-import type { JSX, ReactNode } from "react";
+import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback";
+import { useEffect, type JSX, type ReactNode } from "react";
export type RendererState =
{ readonly status: "opening" | "ready" } | { readonly status: "error"; readonly message: string };
@@ -14,6 +15,12 @@ export function RendererStage({
readonly errorName: string;
readonly children: ReactNode;
}): JSX.Element {
+ const { reportError } = useFeedback();
+ useEffect(() => {
+ if (state.status === "error") {
+ reportError({ code: "preview_failed" });
+ }
+ }, [state.status, reportError]);
return (
{children}
@@ -49,6 +56,7 @@ export function DocumentMessage({
role={tone === "error" ? "alert" : "status"}
>
{label}
+ {tone === "error" ?
: null}
{action ? (
) : null}
{display}
+
{display}
+
{bar.actions}
diff --git a/apps/reader/src/ReaderWorkspaceView.tsx b/apps/reader/src/ReaderWorkspaceView.tsx
index e5e4a77..432a378 100644
--- a/apps/reader/src/ReaderWorkspaceView.tsx
+++ b/apps/reader/src/ReaderWorkspaceView.tsx
@@ -5,6 +5,7 @@ import { confirmCollectionSwitch } from "./collection-switching.js";
import { DeploymentUpdateNotice } from "./DeploymentUpdateNotice.js";
import { inspectorPanelId, navigatorPanelId } from "./dockview-workspace-state.js";
import { DockviewWorkspace } from "./DockviewWorkspace.js";
+import { useReaderFeedbackContext } from "./FeedbackRoot.js";
import { importHref } from "./import-navigation.js";
import { inspectorSourceForTab } from "./inspector-source.js";
import { InspectorPane, type InspectorTab } from "./InspectorPane.js";
@@ -95,6 +96,7 @@ export function ReaderWorkspaceView({
readonly model: ReaderWorkspaceViewModel;
}): JSX.Element {
const { library, source, workspace, sourceWorkspace, composer, sourceAddition } = model;
+ useReaderFeedbackContext(Boolean(sourceWorkspace.activeSourceId), library.collectionName);
const shell = useWorkspaceShellPreferences(
library.sources[0]?.collectionId ?? library.collectionName,
);
diff --git a/apps/reader/src/env.d.ts b/apps/reader/src/env.d.ts
index 0ee4d61..a8633e1 100644
--- a/apps/reader/src/env.d.ts
+++ b/apps/reader/src/env.d.ts
@@ -2,6 +2,8 @@
interface ImportMetaEnv {
readonly VITE_MDBASE_ENV?: 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_MDBASE_READER_BUILD_ID?: string;
diff --git a/apps/reader/src/feedback-shell.css b/apps/reader/src/feedback-shell.css
new file mode 100644
index 0000000..d847de8
--- /dev/null
+++ b/apps/reader/src/feedback-shell.css
@@ -0,0 +1,15 @@
+@media (max-width: 900px) {
+ .reader-header .mdbase-feedback-trigger {
+ min-width: 44px;
+ min-height: 44px;
+ padding: 8px;
+ }
+
+ .reader-header .mdbase-feedback-trigger > span {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ }
+}
diff --git a/apps/reader/src/main.tsx b/apps/reader/src/main.tsx
index 7529472..109c8ad 100644
--- a/apps/reader/src/main.tsx
+++ b/apps/reader/src/main.tsx
@@ -6,9 +6,12 @@ import "./reader.css";
import "./reader-improvements.css";
import "./annotation-polish.css";
import "./reader-shell.css";
+import "@mdbase-dev/ui/feedback.css";
+import "./feedback-shell.css";
import { ConnectReader } from "./ConnectReader.js";
import { EnvironmentBadge } from "./EnvironmentBadge.js";
+import { FeedbackRoot } from "./FeedbackRoot.js";
import { forgetOfflineCopies } from "./forget-offline-copies.js";
import { importService } from "./import-navigation.js";
import { keepFocusedFieldInView } from "./keep-focused-field-in-view.js";
@@ -37,17 +40,19 @@ keepFocusedFieldInView(window);
const migrationService = importService(location.pathname);
createRoot(root).render(
-
- {migrationService ? (
-
-
-
- ) : new URL(location.href).searchParams.has("preview") ? (
-
-
-
- ) : (
-
- )}
+
+
+ {migrationService ? (
+
+
+
+ ) : new URL(location.href).searchParams.has("preview") ? (
+
+
+
+ ) : (
+
+ )}
+
,
);
diff --git a/apps/reader/vite.config.ts b/apps/reader/vite.config.ts
index 7cbe278..9b3cd79 100644
--- a/apps/reader/vite.config.ts
+++ b/apps/reader/vite.config.ts
@@ -12,6 +12,8 @@ export default defineConfig({
plugins: [react(), deploymentRevision(buildId)],
resolve: {
dedupe: [
+ "react",
+ "react-dom",
"@codemirror/autocomplete",
"@codemirror/commands",
"@codemirror/language",
diff --git a/docs/shared-feedback.md b/docs/shared-feedback.md
new file mode 100644
index 0000000..ec56009
--- /dev/null
+++ b/docs/shared-feedback.md
@@ -0,0 +1,51 @@
+# Private feedback integration
+
+This branch uses the shared `@mdbase-dev/ui/feedback` provider, form, screenshot
+capture/markup and verification. It adds no email adapter, telemetry or storage.
+The provider stays above connection/workspace lifetimes so cancelling or changing
+views preserves the draft. Header entries remain available on desktop and mobile;
+connection and document failures have a report entry.
+
+Application metadata uses fixed connection/library/document identifiers, a bounded
+build ID and deployment environment. Collection names and diagnostics require
+explicit consent; source titles, IDs, paths, URLs, credentials and raw exceptions
+are never passed to the feedback API. Only active source-open, renderer and
+explicit connection/setup failures nudge the bug. Aborted/superseded operations
+and background refreshes do not. Global capture-phase shortcuts ignore dialogs.
+
+## Release dependency
+
+The integration now uses the published `@mdbase-dev/ui@0.1.0-beta.124`, which
+contains the feedback exports and was published through the coordinated Connect
+release. All workspace UI pins and the lockfile use that actual version. Verify
+with a fresh frozen-lockfile install, full CI and the intercepted browser
+acceptance; local worktree links are not release evidence.
+
+Deploy the v1/v2-compatible feedback Worker first via guarded cloud-ops. Approve
+exact Reader origins in CORS and the separate Turnstile widgets before enabling
+these build variables:
+
+- `VITE_MDBASE_FEEDBACK_URL`: approved environment's `/v1/feedback` endpoint.
+- `VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY`: that environment's public widget key.
+
+Unset/invalid endpoints hide feedback. Deployment workflows map the
+environment-scoped public variables `MDBASE_FEEDBACK_URL` and
+`MDBASE_FEEDBACK_TURNSTILE_SITE_KEY` to those build variables. Keep them unset
+until the corresponding Worker CORS policy, widget hosts and live acceptance
+are ready. Existing tooling supplies `VITE_MDBASE_ENV` and
+`VITE_MDBASE_READER_BUILD_ID`; no implicit production endpoint is selected.
+
+The existing deployment contract identifies the exact candidate origins:
+`https://staging.mdbase-reader.pages.dev` (staging) and
+`https://reader.mdbase.dev` (production). These are a configuration proposal, not
+an assertion that CORS/widget-host approval or configuration has happened.
+No production widget, secret or deployment is changed by this source PR.
+
+## Sample-data acceptance
+
+Build with a loopback feedback URL and serve the output on loopback port 8891.
+Run `node apps/reader/scripts/feedback-browser-test.mjs`; override the loopback
+origin with `READER_FEEDBACK_TEST_ORIGIN`. It uses `?preview`, intercepts every
+feedback POST, and checks desktop/mobile focus, draft restoration, explicit
+capture cancellation, shortcut isolation, metadata and default consent. No
+native chooser or real delivery is exercised; those remain separate acceptance.