Files
multica/packages/views/editor/attachment-preview-modal.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

738 lines
26 KiB
TypeScript

"use client";
/**
* AttachmentPreviewModal — full-screen inline preview for an attachment.
*
* Single modal for every previewable kind. Handles 7 PreviewKinds:
*
* - image : <img> on the shared ZoomCanvas — fit on open, then wheel /
* drag / pinch / double-click / keyboard zoom, same controls as
* the Mermaid viewer. Replaces the previous standalone
* ImageLightbox.
* - pdf : <iframe src={download_url}> — relies on Chromium's PDFium
* plugin. On desktop, requires webPreferences.plugins=true
* (see apps/desktop/src/main/index.ts).
* - video : <video controls src={download_url}>
* - audio : <audio controls src={download_url}>
*
* - markdown : fetch text via api.getAttachmentTextContent, render via
* the existing ReadonlyContent (full mention/mermaid/katex
* pipeline included).
* - html : fetch text, hand to <iframe srcdoc={text}
* sandbox="allow-scripts">. The iframe runs in an opaque
* origin: scripts execute (chart libraries / vanilla SVG
* JS work), but cookie / localStorage / parent access /
* top-navigation / popups / forms stay blocked because
* `allow-same-origin` is intentionally NOT included.
* - text : fetch text, highlight with lowlight if the extension
* maps to a known hljs language; otherwise plain <pre>.
*
* Media types load directly from the CloudFront signed `download_url`.
* Text types go through `/api/attachments/{id}/content` to sidestep
* CloudFront CORS (not configured) + Content-Disposition: attachment.
*/
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
PreviewTooLargeError,
PreviewUnsupportedError,
} from "@multica/core/api";
import { Download, ExternalLink, FileText, Loader2, X } from "lucide-react";
import type { Attachment } from "@multica/core/types";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { cn } from "@multica/ui/lib/utils";
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
import {
UI_EASE_OUT,
UI_MOTION_DURATION,
} from "@multica/ui/lib/motion";
import { useT } from "../i18n";
import { useNavigation } from "../navigation";
import { openExternal } from "../platform";
import { ReadonlyContent } from "./readonly-content";
import {
extensionToLanguage,
getPreviewKind,
type PreviewKind,
} from "./utils/preview";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
import { useZoomCanvas, type ZoomCanvasApi } from "./hooks/use-zoom-canvas";
import { ZoomCanvas, ZoomControls } from "./zoom-canvas";
import type { Size } from "./utils/zoom-transform";
import { HtmlPreviewBody } from "./html-preview-body";
import { CodeBlockStatic } from "./code-block-static";
// ---------------------------------------------------------------------------
// Preview source — full attachment, or URL-only (media types only)
// ---------------------------------------------------------------------------
//
// `full` carries the resolved Attachment record and supports every PreviewKind
// (text types require the attachment id to call /api/attachments/{id}/content).
//
// `url` carries just the signed URL + filename. It is what NodeViews fall back
// to when `resolveAttachment(href)` returns undefined — typical when the URL
// was copy-pasted across comments so the attachment record isn't reachable
// from the current entity's `attachments` prop. Only media kinds (pdf / video
// / audio) can be opened from a `url` source because those render directly
// from the URL without hitting the text-content proxy.
export type PreviewSource =
| { kind: "full"; attachment: Attachment }
| { kind: "url"; url: string; filename: string };
// PreviewKinds that can render from a URL-only source. Text-based kinds
// (markdown / html / text) need the /content proxy which is ID-keyed.
const URL_ONLY_KINDS = new Set<PreviewKind>(["image", "pdf", "video", "audio"]);
// Normalized view used everywhere downstream of `useAttachmentPreview`.
// `attachmentId === null` signals URL-only mode (download falls back to
// `openExternal`, text rendering branches are unreachable by the gate).
interface PreviewState {
filename: string;
contentType: string;
mediaUrl: string;
attachmentId: string | null;
}
function resolvePreviewMediaUrl(attachment: Attachment): string {
const raw =
attachment.download_url || attachment.markdown_url || attachment.url;
return resolvePublicFileUrl(raw) ?? raw;
}
function normalize(source: PreviewSource): PreviewState {
// Resolve any server-relative URL (e.g. `/api/attachments/{id}/download`
// returned by the unified-endpoint metadata path when no CloudFront
// signer is configured) against the configured API base. Web with the
// default empty base keeps the relative path and resolves it against
// the page origin — same behaviour as before this PR. Desktop renderer
// (loaded from `app://` / file: / dev-server origin) needs the absolute
// form so `<img src>` / `<iframe src>` / `<video src>` actually point at
// the API server instead of the shell origin.
if (source.kind === "full") {
return {
filename: source.attachment.filename,
contentType: source.attachment.content_type,
mediaUrl: resolvePreviewMediaUrl(source.attachment),
attachmentId: source.attachment.id,
};
}
return {
filename: source.filename,
contentType: "",
mediaUrl: resolvePublicFileUrl(source.url) ?? source.url,
attachmentId: null,
};
}
// ---------------------------------------------------------------------------
// Public props
// ---------------------------------------------------------------------------
interface AttachmentPreviewModalProps {
source: PreviewSource;
open: boolean;
onClose: () => void;
}
// ---------------------------------------------------------------------------
// Hook — local state + ready-to-mount modal JSX
// ---------------------------------------------------------------------------
//
// Why no React context / provider: packages/views/ cannot mount a Context.Provider
// inside CoreProvider (in packages/core/), and threading a new provider through
// every app layout is more friction than it's worth for a feature with at most
// one open modal at a time. Instead each entry point gets its own local state
// and renders the returned `modal` node. Multiple entry points coexisting just
// means each carries its own (collapsed) state — they never collide because
// only one preview is open per user click.
export interface AttachmentPreviewHandle {
/** Try to open a preview for the source. Returns false when the file type
* isn't previewable, OR when the source is URL-only but the kind requires
* a full attachment (text/markdown/html). Callers can fall back to a
* download flow. */
tryOpen: (source: PreviewSource) => boolean;
/** Force-open a preview, skipping the previewable() guard. Use for cases
* where the caller has already filtered. */
open: (source: PreviewSource) => void;
/** Modal node to render somewhere in the caller's tree. Resolves to `null`
* when no preview is active. Safe to render inside any container — the
* modal portals to document.body. */
modal: ReactNode;
}
export function useAttachmentPreview(): AttachmentPreviewHandle {
const [current, setCurrent] = useState<PreviewSource | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const open = useCallback((source: PreviewSource) => {
setCurrent(source);
setPreviewOpen(true);
}, []);
const tryOpen = useCallback((source: PreviewSource) => {
const state = normalize(source);
const kind = getPreviewKind(state.contentType, state.filename);
if (!kind) return false;
// URL-only sources cannot drive text kinds — the /content proxy is ID-keyed.
if (source.kind === "url" && !URL_ONLY_KINDS.has(kind)) return false;
setCurrent(source);
setPreviewOpen(true);
return true;
}, []);
const modal = useMemo(
() =>
current ? (
<AttachmentPreviewModal
source={current}
open={previewOpen}
onClose={() => setPreviewOpen(false)}
onExitComplete={() => setCurrent(null)}
/>
) : null,
[current, previewOpen],
);
return useMemo(() => ({ open, tryOpen, modal }), [open, tryOpen, modal]);
}
// ---------------------------------------------------------------------------
// Modal — frame + dispatch
// ---------------------------------------------------------------------------
export function AttachmentPreviewModal({
source,
open,
onClose,
onExitComplete,
}: AttachmentPreviewModalProps & { onExitComplete?: () => void }) {
const download = useDownloadAttachment();
const shouldReduceMotion = useReducedMotion() ?? false;
const state = normalize(source);
// useWorkspaceSlug (not useWorkspacePaths) — returns null outside a
// workspace route instead of throwing, so the new-tab button just hides.
const slug = useWorkspaceSlug();
const navigation = useNavigation();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
const kind = getPreviewKind(state.contentType, state.filename);
// Download dispatcher: re-sign through `getAttachment` when an id is
// available; otherwise fall back to opening the (possibly stale) URL
// externally — same tradeoff as the file-card NodeView's download path.
const handleDownload = () => {
if (state.attachmentId) {
download(state.attachmentId);
} else {
openExternal(state.mediaUrl);
}
};
// Open-in-new-tab mirrors HtmlAttachmentPreview's inline toolbar: only the
// `html` kind has a dedicated full-page route (/attachments/{id}/preview).
// Gated on slug + attachmentId for the same reason — URL-only sources
// can't address the /content proxy the page relies on.
const canOpenInNewTab = kind === "html" && !!slug && !!state.attachmentId;
const handleOpenInNewTab = () => {
if (!slug || !state.attachmentId) return;
const nameQuery = state.filename
? `?name=${encodeURIComponent(state.filename)}`
: "";
const path = `${paths.workspace(slug).attachmentPreview(state.attachmentId)}${nameQuery}`;
if (navigation.openInNewTab) {
navigation.openInNewTab(path, state.filename, { activate: true });
} else {
const url = navigation.getShareableUrl(path);
window.open(url, "_blank", "noopener,noreferrer");
}
onClose();
};
if (typeof document === "undefined") return null;
return createPortal(
<AnimatePresence onExitComplete={onExitComplete}>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
// Only a click that lands on the backdrop itself closes. A pan that
// starts on the zoom canvas and releases out here retargets its
// click through pointer capture, but this makes the intent explicit
// instead of relying on that.
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
aria-label={state.filename}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: UI_MOTION_DURATION.fast,
ease: UI_EASE_OUT,
},
}}
exit={{
opacity: 0,
transition: {
duration: UI_MOTION_DURATION.fast,
ease: UI_EASE_OUT,
},
}}
>
{/* Larger than the create-issue dialog (max-w-4xl, manualDialogContentClass)
because PDF / video previews want more room. Capped to viewport
minus the surrounding p-4 (1rem each side) so it never overflows
the screen on small displays / split panes. */}
<motion.div
className="flex h-[min(90vh,calc(100vh-2rem))] w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-background shadow-xl"
onClick={(e) => e.stopPropagation()}
initial={{
opacity: 0,
transform: shouldReduceMotion ? "scale(1)" : "scale(0.95)",
}}
animate={{
opacity: 1,
transform: "scale(1)",
transition: {
duration: UI_MOTION_DURATION.standard,
ease: UI_EASE_OUT,
},
}}
exit={{
opacity: 0,
transform: shouldReduceMotion ? "scale(1)" : "scale(0.95)",
transition: {
duration: UI_MOTION_DURATION.fast,
ease: UI_EASE_OUT,
},
}}
>
{/* Below the `open &&` gate on purpose: the panel's zoom state is
destroyed on close, so every open re-fits instead of restoring
a stale zoom from the last time this image was viewed. */}
<PreviewPanel
kind={kind}
source={source}
state={state}
onClose={onClose}
onDownload={handleDownload}
onOpenInNewTab={canOpenInNewTab ? handleOpenInNewTab : undefined}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Panel — header + content area
// ---------------------------------------------------------------------------
// Header chrome and the content area live together because the image kind's
// zoom controls sit in the header while the canvas they drive is the body:
// one owner for that shared state, mounted and destroyed with the open modal.
function PreviewPanel({
kind,
source,
state,
onClose,
onDownload,
onOpenInNewTab,
}: {
kind: PreviewKind | null;
source: PreviewSource;
state: PreviewState;
onClose: () => void;
onDownload: () => void;
onOpenInNewTab?: () => void;
}) {
const { t } = useT("editor");
// Natural size is carried with the URL it was measured from, so a panel
// reused for a different attachment can never fit the new image against the
// old one's dimensions.
const [measured, setMeasured] = useState<{ url: string; size: Size } | null>(
null,
);
const natural =
kind === "image" && measured?.url === state.mediaUrl ? measured.size : null;
const canvas = useZoomCanvas({ content: natural });
const handleNaturalSize = useCallback(
(url: string, size: Size) => {
setMeasured((previous) =>
previous?.url === url &&
previous.size.width === size.width &&
previous.size.height === size.height
? previous
: { url, size },
);
},
[],
);
return (
<>
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<p className="truncate text-body font-medium">{state.filename}</p>
<span className="ml-1 shrink-0 text-caption text-muted-foreground">
{state.contentType || "—"}
</span>
<div className="ml-auto flex items-center gap-1">
{/* Only once the image has been measured: without a natural size
there is no canvas behind these buttons to drive. */}
{natural && <ZoomControls canvas={canvas} className="mr-1" />}
{onOpenInNewTab && (
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.open_in_new_tab)}
aria-label={t(($) => $.attachment.open_in_new_tab)}
onClick={onOpenInNewTab}
>
<ExternalLink className="size-4" />
</button>
)}
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={onDownload}
>
<Download className="size-4" />
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.close)}
aria-label={t(($) => $.attachment.close)}
onClick={onClose}
>
<X className="size-4" />
</button>
</div>
</div>
{/* Image gets a flex column: the canvas sizes itself with `flex: 1 1
auto` and its content is absolutely positioned, so in a plain block
parent it would collapse to zero height and show nothing. It also
clips and handles its own wheel events — letting this wrapper scroll
too would fight the pan. Every other kind keeps the block scroller;
making them flex items would let tall text previews shrink to fit
instead of scrolling. */}
<div
className={cn(
"min-h-0 flex-1 bg-background",
kind === "image" ? "flex flex-col overflow-hidden" : "overflow-auto",
)}
>
{kind === "image" ? (
<ImagePreview
state={state}
canvas={canvas}
natural={natural}
onNaturalSize={handleNaturalSize}
/>
) : (
<PreviewContent
kind={kind}
source={source}
state={state}
onDownload={onDownload}
/>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Image — zoom canvas
// ---------------------------------------------------------------------------
function ImagePreview({
state,
canvas,
natural,
onNaturalSize,
}: {
state: PreviewState;
canvas: ZoomCanvasApi;
natural: Size | null;
onNaturalSize: (url: string, size: Size) => void;
}) {
const { t } = useT("editor");
const url = state.mediaUrl;
const readNaturalSize = useCallback(
(image: HTMLImageElement | null) => {
// naturalWidth is 0 for an image that hasn't decoded yet, and also for
// an SVG that declares only a viewBox — Chromium gives those no
// intrinsic size at all. Both fall back to the letterboxed branch;
// the first recovers on load, the second stays there.
if (!image || image.naturalWidth <= 0 || image.naturalHeight <= 0) return;
onNaturalSize(url, {
width: image.naturalWidth,
height: image.naturalHeight,
});
},
[onNaturalSize, url],
);
return (
<ZoomCanvas
canvas={canvas}
content={natural}
label={t(($) => $.image.canvas_label)}
className="bg-black/40"
autoFocus
>
<img
// A cached image is already `complete` before React attaches onLoad,
// so that event never fires — measure from the ref as well.
ref={readNaturalSize}
onLoad={(e) => readNaturalSize(e.currentTarget)}
src={url}
alt={state.filename}
className={cn(
"select-none",
natural
? "block size-full"
: "max-h-full max-w-full rounded-lg object-contain",
)}
// Native image dragging would hijack the pan gesture.
draggable={false}
/>
</ZoomCanvas>
);
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
// Dispatch on PreviewKind. New cases go here; remember that the modal frame
// (header, close, Download CTA, ESC handling) is shared — sub-renderers only
// own the content area. `image` is handled by PreviewPanel itself because its
// toolbar and canvas share zoom state.
function PreviewContent({
kind,
source,
state,
onDownload,
}: {
kind: Exclude<PreviewKind, "image"> | null;
source: PreviewSource;
state: PreviewState;
onDownload: () => void;
}) {
const { t } = useT("editor");
if (kind === null) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
// Text kinds need the attachment id for the /content proxy. The tryOpen
// gate prevents URL-only sources from reaching here for text kinds, but
// be defensive — a direct mount of <AttachmentPreviewModal> with a URL
// source whose filename later resolves to a text kind would otherwise
// crash on a null id.
if (
(kind === "markdown" || kind === "html" || kind === "text") &&
!state.attachmentId
) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
switch (kind) {
case "pdf":
return (
<iframe
src={state.mediaUrl}
className="h-full w-full bg-background"
title={state.filename}
/>
);
case "video":
return (
<div className="flex h-full w-full items-center justify-center bg-black">
<video
src={state.mediaUrl}
controls
className="h-full w-full object-contain"
/>
</div>
);
case "audio":
return (
<div className="flex h-full w-full items-center justify-center p-8">
<audio src={state.mediaUrl} controls className="w-full max-w-xl" />
</div>
);
case "markdown":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<ReadonlyContent
content={text}
className="px-6 py-4"
attachments={source.kind === "full" ? [source.attachment] : []}
/>
)}
/>
);
case "html":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<HtmlPreviewBody
source={{ kind: "inline", html: text }}
title={state.filename}
className="h-full w-full"
iframeClassName="rounded-none border-0"
/>
)}
/>
);
case "text":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<CodeBlockStatic
language={extensionToLanguage(state.filename)}
body={text}
className="px-6 py-4"
/>
)}
/>
);
}
}
// ---------------------------------------------------------------------------
// Text-backed preview — fetches body once, then hands to the render prop
// ---------------------------------------------------------------------------
// React Query owns server state per the project convention; re-opening the
// same attachment hits the cache instead of re-fetching. Query is keyed on
// the attachment id alone — the 30 min TTL on the server-side signed URL
// is much longer than any plausible preview session.
function TextBackedPreview({
attachmentId,
onDownload,
render,
}: {
attachmentId: string;
onDownload: () => void;
render: (text: string) => ReactNode;
}) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
if (query.isLoading) {
return (
<div className="flex h-full items-center justify-center gap-2 text-body text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t(($) => $.attachment.preview_loading)}
</div>
);
}
if (query.error) {
if (query.error instanceof PreviewTooLargeError) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_too_large)}
onDownload={onDownload}
/>
);
}
if (query.error instanceof PreviewUnsupportedError) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_failed)}
onDownload={onDownload}
/>
);
}
if (!query.data) return null;
return <>{render(query.data.text)}</>;
}
// ---------------------------------------------------------------------------
// Fallback — used for 413 / 415 / unknown kinds
// ---------------------------------------------------------------------------
function UnsupportedFallback({
message,
onDownload,
}: {
message: string;
onDownload: () => void;
}) {
const { t } = useT("editor");
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<FileText className="size-8 text-muted-foreground" />
<p className="text-body text-muted-foreground">{message}</p>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-body transition-colors hover:bg-muted"
onClick={onDownload}
>
<Download className="size-4" />
{t(($) => $.image.download)}
</button>
</div>
);
}
// Re-export the predicate from the dispatch util so entry-point components
// only need a single import to gate the Eye button.
export { isPreviewable } from "./utils/preview";