diff --git a/apps/extension/README.md b/apps/extension/README.md
index b5ac15df..4420751d 100644
--- a/apps/extension/README.md
+++ b/apps/extension/README.md
@@ -29,7 +29,7 @@ HN usage and verification limits are documented in [English](../../docs/browser-
## UI architecture
-Extension pages use React, shared shadcn/ui-style Button and Input components, and Tailwind CSS 4 with a neutral theme. The webpage feedback UI uses Sonner inside a Shadow DOM, bundled as a standalone classic script. Its stylesheet stays inside the shadow root; image readers and content extractors do not load React. Permission requests remain in click handlers.
+Extension pages use React, shared shadcn/ui-style Button and Input components, and Tailwind CSS 4 with a neutral theme. The webpage feedback UI uses the official shadcn/ui Sonner component structure, adapted for an explicit extension theme, inside a Shadow DOM. Extension pages and notifications share theme tokens. The feedback entry only mounts the component and maps clipping states, and is bundled as a standalone classic script. Its stylesheet stays inside the shadow root; image readers and content extractors do not load React. Permission requests remain in click handlers.
## Localization
diff --git a/apps/extension/src/ui/clip-feedback.tsx b/apps/extension/src/ui/clip-feedback.tsx
index 656150d5..8e9810bd 100644
--- a/apps/extension/src/ui/clip-feedback.tsx
+++ b/apps/extension/src/ui/clip-feedback.tsx
@@ -1,6 +1,6 @@
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
-import { Toaster, toast } from "sonner";
+import { Toaster, toast, notificationDuration } from "./components/sonner";
// Re-injection reuses the mounted component and its Sonner store. Keep both the
// stylesheet and React tree inside the shadow root, away from the site's CSS.
@@ -26,11 +26,11 @@ if (payload?.action === "toast") {
shadow.append(style, mount);
(document.documentElement ?? document.body).appendChild(host);
const root = createRoot(mount);
- flushSync(() => root.render());
+ flushSync(() => root.render());
service = {
host,
show(message, kind) {
- const options = { id: "edgeever-clip", duration: kind === "error" ? 8000 : 4000, style: { background: kind === "error" ? "#fff1f2" : "#fff", color: kind === "error" ? "#be123c" : "#334155", border: kind === "error" ? "1px solid #ffe4e6" : "1px solid #e2e8f0", pointerEvents: "auto" as const } };
+ const options = { id: "edgeever-clip", duration: notificationDuration[kind] };
if (kind === "error") toast.error(message, options);
else if (kind === "success") toast.success(message, options);
else toast(message, options);
diff --git a/apps/extension/src/ui/components/sonner.LICENSE b/apps/extension/src/ui/components/sonner.LICENSE
new file mode 100644
index 00000000..fad4d887
--- /dev/null
+++ b/apps/extension/src/ui/components/sonner.LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 shadcn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/extension/src/ui/components/sonner.tsx b/apps/extension/src/ui/components/sonner.tsx
new file mode 100644
index 00000000..2d262440
--- /dev/null
+++ b/apps/extension/src/ui/components/sonner.tsx
@@ -0,0 +1,43 @@
+// Adapted from shadcn/ui's official new-york-v4 Sonner component (MIT):
+// https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/sonner.tsx
+// Extension pages provide their theme explicitly rather than using next-themes.
+import type { CSSProperties } from "react";
+import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+export const notificationDuration = { normal: 4000, success: 4000, error: 8000 } as const;
+
+export function Toaster({
+ theme = "light", position = "bottom-right", offset = 16, mobileOffset = 16,
+ visibleToasts = 1, closeButton = true, hotkey = [], icons, style, toastOptions, ...props
+}: ToasterProps) {
+ return ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ ...icons,
+ }}
+ style={{
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ ...style,
+ } as CSSProperties}
+ toastOptions={{ duration: notificationDuration.normal, ...toastOptions }}
+ {...props}
+ />;
+}
+
+export { toast } from "sonner";
diff --git a/apps/extension/src/ui/notification.css b/apps/extension/src/ui/notification.css
new file mode 100644
index 00000000..5fc63499
--- /dev/null
+++ b/apps/extension/src/ui/notification.css
@@ -0,0 +1,10 @@
+/* Sonner provides layout, motion and interaction. Only theme tokens vary here. */
+.toaster [data-sonner-toast] {
+ pointer-events: auto;
+}
+
+.toaster [data-sonner-toast][data-type="error"] {
+ --normal-bg: var(--notification-error-background);
+ --normal-text: var(--notification-error-foreground);
+ --normal-border: var(--notification-error-border);
+}
diff --git a/apps/extension/src/ui/theme-tokens.css b/apps/extension/src/ui/theme-tokens.css
new file mode 100644
index 00000000..dfa95e3a
--- /dev/null
+++ b/apps/extension/src/ui/theme-tokens.css
@@ -0,0 +1,19 @@
+/* One palette for extension pages and the Shadow DOM notification component. */
+:root,
+:host {
+ --background: #f5f5f5;
+ --foreground: #212121;
+ --card: #fff;
+ --primary: #171717;
+ --primary-foreground: #fff;
+ --muted: #f5f5f5;
+ --muted-foreground: #666666;
+ --popover: #fff;
+ --popover-foreground: #334155;
+ --border: #e0e0e0;
+ --ring: #262626;
+ --radius: 0.5rem;
+ --notification-error-background: #fff1f2;
+ --notification-error-foreground: #be123c;
+ --notification-error-border: #ffe4e6;
+}
diff --git a/apps/extension/src/ui/theme.css b/apps/extension/src/ui/theme.css
index 6d4dd125..972b41d8 100644
--- a/apps/extension/src/ui/theme.css
+++ b/apps/extension/src/ui/theme.css
@@ -1,4 +1,6 @@
@import "tailwindcss" source(none);
+@import "./theme-tokens.css";
+@import "./notification.css";
@source "./";
@source "../../../web/src/components/ui/button.tsx";
@source "../../../web/src/components/ui/input.tsx";
@@ -6,15 +8,15 @@
@source "../../../web/src/components/ui/tooltip.tsx";
@theme inline {
- --color-background: #f5f5f5;
- --color-foreground: #212121;
- --color-card: #fff;
- --color-primary: #171717;
- --color-primary-foreground: #fff;
- --color-muted: #f5f5f5;
- --color-muted-foreground: #666666;
- --color-border: #e0e0e0;
- --color-ring: #262626;
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-border: var(--border);
+ --color-ring: var(--ring);
}
@layer base {
diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts
index fc501226..0ce506af 100644
--- a/apps/extension/vite.config.ts
+++ b/apps/extension/vite.config.ts
@@ -21,7 +21,7 @@ export default defineConfig({
tailwindcss(),
{
name: "edgeever-extension-manifest",
- async generateBundle() {
+ async generateBundle(_options, bundle) {
this.emitFile({
type: "asset",
fileName: "manifest.json",
@@ -47,13 +47,20 @@ export default defineConfig({
source: capture.outputFiles[0].contents,
});
}
+ // Reuse the generated Tailwind stylesheet so the shadcn component and
+ // its theme tokens behave identically in pages and in the shadow root.
+ const themeStyles = Object.values(bundle)
+ .filter((item) => item.type === "asset" && item.fileName.endsWith(".css"))
+ .map((item) => item.type === "asset" ? typeof item.source === "string" ? item.source : new TextDecoder().decode(item.source) : "")
+ .join("\n");
+ if (!themeStyles) throw new Error("Missing compiled extension theme for shadow notifications");
const feedback = await buildStandaloneScript({
entryPoints: [fileURLToPath(new URL("./src/ui/clip-feedback.tsx", import.meta.url))],
bundle: true, format: "iife", platform: "browser", target: "es2022", minify: true, write: false,
jsx: "automatic",
define: {
"process.env.NODE_ENV": '"production"',
- __CLIP_FEEDBACK_CSS__: JSON.stringify(await readFile(require.resolve("sonner/dist/styles.css"), "utf8")),
+ __CLIP_FEEDBACK_CSS__: JSON.stringify(`${await readFile(require.resolve("sonner/dist/styles.css"), "utf8")}\n${themeStyles}`),
},
plugins: [{
name: "shadow-only-sonner-styles",