Files
multica/packages/views/plugins/plugin-modal-surface.tsx
f0c361ebca feat(ui): Multica icons, roomier menus and typography pass (MUL-8334, MUL-8338) (#9640)
* refactor(ui): add @multica/ui/icons as the single icon entry point (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor: import icons from @multica/ui/icons instead of lucide-react (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(lint): ban direct lucide-react imports outside @multica/ui/icons (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): draw Multica icons and enlarge primitive icon sizes (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): roomier menu and sidebar rhythm (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): mark body and detail layers in icon glyphs (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): Inter optical sizing, lighter titles, CJK leading and zh-TW font stack (MUL-8338)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): 13px for in-place reading text, regular weight for descriptive labels (MUL-8338)

Issue detail timeline, comment headers, properties panel, wakeup and PR rows
move from 12px to 13px; sub-issue identifiers match list/board (12px regular);
sidebar group labels drop to regular weight; board card titles get more
leading under a CJK UI via a new cjk: variant.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): tint the active sidebar icon's body with a light fill (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): draw every remaining icon and drop lucide-react (MUL-8334)

All 286 icon names now render Multica drawings. The drawings, their
construction helpers and the generator live in packages/ui/icons/drawings;
glyphs.ts and index.ts are generated and a test fails when they drift.
Search loses its lens highlight and Inbox its arrow.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore: guard against icon libraries and document drawing new icons (MUL-8334)

Lint bans the common React icon libraries, AGENTS.md points to a new
multica-icons skill, route icons must mark a body for the active tint,
and comments no longer describe product icons as Lucide.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(ui): fill the active sidebar icon solid with its details knocked out (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(lint): ban icon library subpaths too (MUL-8334)

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 23:52:28 +08:00

139 lines
4.8 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plug } from "@multica/ui/icons";
import { useFeatureEnabled } from "@multica/core/config";
import { PLUGINS_V1_FLAG } from "@multica/core/feature-flags";
import { useCurrentWorkspace } from "@multica/core/paths";
import { pluginInstallationsOptions } from "@multica/core/plugins";
import type { PluginInstallation, PluginSurface } from "@multica/core/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { PluginSurfaceFrame } from "./plugin-surface-frame";
/**
* The mount point for `modal` surfaces.
*
* A modal is the same hosted, opaque inner iframe as a panel — with a CSP
* derived from the granted `net:` scopes — differing only in where it appears.
* What it is NOT is a way
* for a plugin to interrupt somebody: it opens because a person picked it from
* the issue menu, never on the plugin's own initiative.
*/
export interface PluginModalTarget {
installation: PluginInstallation;
surface: PluginSurface;
}
export function pluginModalKey(target: PluginModalTarget): string {
return `${target.installation.id}:${target.surface.key}`;
}
/**
* Collects modal surfaces from enabled installations.
*
* Exported for its own test: the filter is the product rule and belongs in one
* place rather than re-derived by each menu that offers these.
*/
export function collectModalSurfaces(installations: readonly PluginInstallation[]): PluginModalTarget[] {
const targets: PluginModalTarget[] = [];
for (const installation of installations) {
// Explicit === true: a backend that stops sending the field must read as
// off, not as truthy enough to open a third party's UI.
if (installation.enabled !== true) continue;
for (const surface of installation.surfaces ?? []) {
if (surface.type === "modal") targets.push({ installation, surface });
}
}
return targets;
}
export function usePluginModalSurfaces(): PluginModalTarget[] {
const pluginsEnabled = useFeatureEnabled(PLUGINS_V1_FLAG, false);
const wsId = useCurrentWorkspace()?.id ?? "";
const { data } = useQuery({
...pluginInstallationsOptions(wsId),
enabled: pluginsEnabled && wsId.length > 0,
});
return useMemo(
() => (pluginsEnabled ? collectModalSurfaces(data?.plugins ?? []) : []),
[data, pluginsEnabled],
);
}
interface PluginModalSurfaceProps {
target: PluginModalTarget | null;
issueId?: string;
onOpenChange: (open: boolean) => void;
}
export function PluginModalSurface({ target, issueId, onOpenChange }: PluginModalSurfaceProps) {
// Same source as usePluginModalSurfaces above: a modal only ever opens from
// the issue menu, which is inside the workspace route.
const workspace = useCurrentWorkspace();
if (!target) return null;
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plug className="size-4 text-muted-foreground" />
{target.surface.name}
</DialogTitle>
{/* Names the plugin, always. Someone looking at a dialog full of
third-party UI is entitled to know whose it is without guessing
from the styling. */}
<DialogDescription>{target.installation.name}</DialogDescription>
</DialogHeader>
<PluginSurfaceFrame
// Keyed on the issue as well as the surface: the frame carries the
// issue into its context call, and an unchanged document would not
// reload for a different one.
key={`${pluginModalKey(target)}:${issueId ?? ""}`}
wsId={workspace?.id ?? ""}
installation={target.installation}
surface={target.surface}
issueId={issueId}
/>
</DialogContent>
</Dialog>
);
}
/** Menu entries that open a modal, in the same shape the hook entries use. */
export function PluginModalMenuItems({
issueId,
Item,
}: {
issueId: string;
Item: React.ComponentType<{ onClick?: () => void; children?: React.ReactNode }>;
}) {
const targets = usePluginModalSurfaces();
const [open, setOpen] = useState<PluginModalTarget | null>(null);
if (targets.length === 0) return null;
return (
<>
{targets.map((target) => (
<Item key={pluginModalKey(target)} onClick={() => setOpen(target)}>
<Plug className="h-3.5 w-3.5" />
<span className="truncate">{target.surface.name}</span>
<span className="ml-auto truncate pl-3 text-caption text-muted-foreground">
{target.installation.name}
</span>
</Item>
))}
<PluginModalSurface target={open} issueId={issueId} onOpenChange={(next) => !next && setOpen(null)} />
</>
);
}