refactor(extension): adopt shadcn Sonner component structure

This commit is contained in:
tianma-if committed 2026-10-12 23:29:47 +08:00
1 parent 143873b18a
commit ab6353f44d
8 files changed
+117 -15

No files matched your search

+1 -1
View File
@@ -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
+3 -3
View File
@@ -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(<Toaster theme="light" position="bottom-right" offset={16} mobileOffset={16} visibleToasts={1} closeButton hotkey={[]} containerAriaLabel={payload.regionLabel} toastOptions={{ closeButtonAriaLabel: payload.closeLabel, style: { background: "#fff", color: "#334155", border: "1px solid #e2e8f0", pointerEvents: "auto" } }} />));
flushSync(() => root.render(<Toaster containerAriaLabel={payload.regionLabel} toastOptions={{ closeButtonAriaLabel: payload.closeLabel }} />));
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);
@@ -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.
@@ -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 <Sonner
theme={theme}
position={position}
offset={offset}
mobileOffset={mobileOffset}
visibleToasts={visibleToasts}
closeButton={closeButton}
hotkey={hotkey}
className="toaster group"
icons={{
success: <CircleCheckIcon size={16} className="size-4" aria-hidden="true" />,
info: <InfoIcon size={16} className="size-4" aria-hidden="true" />,
warning: <TriangleAlertIcon size={16} className="size-4" aria-hidden="true" />,
error: <OctagonXIcon size={16} className="size-4" aria-hidden="true" />,
loading: <Loader2Icon size={16} className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />,
...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";
+10
View File
@@ -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);
}
+19
View File
@@ -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;
}
+11 -9
View File
@@ -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 {
+9 -2
View File
@@ -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",