mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): implement quick theme switching in entry view and enhance plugin details modal
- Added a quick theme switch feature in the `EntryShell` and `EntryView` components, allowing users to toggle between system, light, and dark themes directly from the avatar-popover dropdown. - Updated the `App` component to handle theme changes and persist user preferences. - Introduced a new `PluginPreviewHero` component in the `PluginDetailsModal` to display example outputs of plugins in a sandboxed iframe, enhancing user interaction with plugin capabilities. - Enhanced CSS styles for improved visual consistency across the updated components. This update significantly improves user experience by providing intuitive theme management and a more engaging plugin preview feature.
This commit is contained in:
parent
9825b3ba1f
commit
583bcaf64f
6 changed files with 527 additions and 118 deletions
|
|
@ -441,6 +441,22 @@ export function App() {
|
|||
[config],
|
||||
);
|
||||
|
||||
// Quick theme switch from the settings dropdown in the entry view.
|
||||
// Skips the full SettingsDialog round-trip so the appearance flip
|
||||
// feels instantaneous; the live preview comes for free because the
|
||||
// `useLayoutEffect` above re-runs `applyAppearanceToDocument` the
|
||||
// moment `config.theme` changes. We still persist to localStorage
|
||||
// and the daemon so the choice survives reloads.
|
||||
const handleThemeChange = useCallback(
|
||||
(theme: AppConfig['theme']) => {
|
||||
const next = { ...config, theme };
|
||||
saveConfig(next);
|
||||
void syncConfigToDaemon(next);
|
||||
setConfig(next);
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback(
|
||||
(agentId: string) => {
|
||||
const next = { ...config, agentId };
|
||||
|
|
@ -810,6 +826,7 @@ export function App() {
|
|||
onAgentModelChange={handleAgentModelChange}
|
||||
onApiProtocolChange={handleApiProtocolChange}
|
||||
onApiModelChange={handleApiModelChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
skillsLoading={skillsLoading}
|
||||
designSystemsLoading={dsLoading}
|
||||
projectsLoading={projectsLoading}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
AgentInfo,
|
||||
ApiProtocol,
|
||||
AppConfig,
|
||||
AppTheme,
|
||||
DesignSystemSummary,
|
||||
ExecMode,
|
||||
Project,
|
||||
|
|
@ -68,6 +69,29 @@ function defaultPluginIdForKind(metadata: ProjectMetadata): string | null {
|
|||
return DEFAULT_SCENARIO_PLUGIN_BY_KIND[metadata.kind] ?? null;
|
||||
}
|
||||
|
||||
// Theme options exposed in the avatar-popover appearance submenu.
|
||||
// Mirrors the segmented control in `SettingsDialog` so the same three
|
||||
// choices (System / Light / Dark) are available from both surfaces.
|
||||
type AppearanceThemeLabel =
|
||||
| 'settings.themeSystem'
|
||||
| 'settings.themeLight'
|
||||
| 'settings.themeDark';
|
||||
|
||||
const APPEARANCE_THEMES: ReadonlyArray<{
|
||||
value: AppTheme;
|
||||
labelKey: AppearanceThemeLabel;
|
||||
}> = [
|
||||
{ value: 'system', labelKey: 'settings.themeSystem' },
|
||||
{ value: 'light', labelKey: 'settings.themeLight' },
|
||||
{ value: 'dark', labelKey: 'settings.themeDark' },
|
||||
];
|
||||
|
||||
const APPEARANCE_LABEL: Record<AppTheme, AppearanceThemeLabel> = {
|
||||
system: 'settings.themeSystem',
|
||||
light: 'settings.themeLight',
|
||||
dark: 'settings.themeDark',
|
||||
};
|
||||
|
||||
type Translator = ReturnType<typeof useT>;
|
||||
|
||||
// Mirrors the chip text the InlineModelSwitcher renders, so the
|
||||
|
|
@ -135,6 +159,10 @@ interface Props {
|
|||
) => void;
|
||||
onApiProtocolChange: (protocol: ApiProtocol) => void;
|
||||
onApiModelChange: (model: string) => void;
|
||||
// Quick theme switch from the avatar-popover dropdown. Lets the user
|
||||
// flip between system / light / dark without opening the full Settings
|
||||
// dialog. App owns persistence; this component just calls the callback.
|
||||
onThemeChange: (theme: AppTheme) => void;
|
||||
onCreateProject: (
|
||||
input: CreateInput & {
|
||||
pendingPrompt?: string;
|
||||
|
|
@ -183,6 +211,7 @@ export function EntryShell({
|
|||
onAgentModelChange,
|
||||
onApiProtocolChange,
|
||||
onApiModelChange,
|
||||
onThemeChange,
|
||||
onCreateProject,
|
||||
onImportClaudeDesign,
|
||||
onImportFolder,
|
||||
|
|
@ -203,6 +232,7 @@ export function EntryShell({
|
|||
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
|
||||
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
|
||||
const [languageExpanded, setLanguageExpanded] = useState(false);
|
||||
const [appearanceExpanded, setAppearanceExpanded] = useState(false);
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
const [useEverywhereOpen, setUseEverywhereOpen] = useState(false);
|
||||
const avatarMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -286,6 +316,7 @@ export function EntryShell({
|
|||
useEffect(() => {
|
||||
if (!avatarMenuOpen) {
|
||||
setLanguageExpanded(false);
|
||||
setAppearanceExpanded(false);
|
||||
return;
|
||||
}
|
||||
const onClick = (e: MouseEvent) => {
|
||||
|
|
@ -424,6 +455,62 @@ export function EntryShell({
|
|||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Appearance — system / light / dark. Mirrors the language
|
||||
picker: a toggle row that expands a nested radio group so
|
||||
the dropdown can host quick theme switching without
|
||||
opening the full Settings dialog. The active theme is
|
||||
echoed in the meta slot so the row reads as status when
|
||||
collapsed. */}
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-item"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={appearanceExpanded}
|
||||
onClick={() => setAppearanceExpanded((v) => !v)}
|
||||
data-testid="entry-avatar-appearance"
|
||||
>
|
||||
<span className="avatar-item-icon" aria-hidden>
|
||||
<Icon name="sun-moon" size={14} />
|
||||
</span>
|
||||
<span>{t('settings.appearance')}</span>
|
||||
<span className="avatar-item-meta">
|
||||
{t(APPEARANCE_LABEL[config.theme ?? 'system'])}
|
||||
</span>
|
||||
<Icon
|
||||
name={appearanceExpanded ? 'chevron-down' : 'chevron-right'}
|
||||
size={11}
|
||||
className="avatar-item-chevron"
|
||||
/>
|
||||
</button>
|
||||
{appearanceExpanded ? (
|
||||
<div
|
||||
className="avatar-language-list"
|
||||
role="group"
|
||||
aria-label={t('settings.appearance')}
|
||||
>
|
||||
{APPEARANCE_THEMES.map(({ value, labelKey }) => {
|
||||
const active = (config.theme ?? 'system') === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
className={`avatar-item avatar-item--lang${active ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
onThemeChange(value);
|
||||
setAvatarMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="avatar-item-icon" aria-hidden>
|
||||
{active ? <Icon name="check" size={14} /> : null}
|
||||
</span>
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }} />
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
AgentInfo,
|
||||
ApiProtocol,
|
||||
AppConfig,
|
||||
AppTheme,
|
||||
DesignSystemSummary,
|
||||
ExecMode,
|
||||
Project,
|
||||
|
|
@ -50,6 +51,10 @@ interface Props {
|
|||
) => void;
|
||||
onApiProtocolChange: (protocol: ApiProtocol) => void;
|
||||
onApiModelChange: (model: string) => void;
|
||||
// Quick theme switch invoked from the avatar-popover dropdown so the
|
||||
// user can flip light/dark/system without opening the full Settings
|
||||
// dialog. Persistence happens in `App`; this component just forwards.
|
||||
onThemeChange: (theme: AppTheme) => void;
|
||||
// Per-resource loading flags. Each tab gates its own content on whichever
|
||||
// flag matches the data it renders, so a slow `/api/agents` probe does
|
||||
// not block tabs that don't need agents. Templates are not gated here —
|
||||
|
|
@ -204,6 +209,7 @@ export function EntryView({
|
|||
onAgentModelChange,
|
||||
onApiProtocolChange,
|
||||
onApiModelChange,
|
||||
onThemeChange,
|
||||
skillsLoading = false,
|
||||
designSystemsLoading = false,
|
||||
projectsLoading = false,
|
||||
|
|
@ -287,6 +293,7 @@ export function EntryView({
|
|||
onAgentModelChange={onAgentModelChange}
|
||||
onApiProtocolChange={onApiProtocolChange}
|
||||
onApiModelChange={onApiModelChange}
|
||||
onThemeChange={onThemeChange}
|
||||
onCreateProject={onCreateProject}
|
||||
onImportClaudeDesign={onImportClaudeDesign}
|
||||
{...(onImportFolder ? { onImportFolder } : {})}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
} from '@open-design/contracts';
|
||||
import { Icon } from './Icon';
|
||||
import { authorInitials, derivePluginSourceLinks } from '../runtime/plugin-source';
|
||||
import { PluginPreviewHero } from './plugin-details/PluginPreviewHero';
|
||||
|
||||
interface Props {
|
||||
record: InstalledPluginRecord;
|
||||
|
|
@ -170,6 +171,47 @@ export function PluginDetailsModal({
|
|||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{hasAuthorBlock ? (
|
||||
<div
|
||||
className="plugin-details-modal__byline"
|
||||
data-testid="plugin-details-author"
|
||||
>
|
||||
<AuthorAvatar
|
||||
name={links.authorName}
|
||||
avatarUrl={links.authorAvatarUrl}
|
||||
/>
|
||||
<div className="plugin-details-modal__byline-meta">
|
||||
{links.authorName ? (
|
||||
<div className="plugin-details-modal__byline-name">
|
||||
<span className="plugin-details-modal__byline-prefix">by</span>
|
||||
<span className="plugin-details-modal__author-name">
|
||||
{links.authorName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="plugin-details-modal__byline-links">
|
||||
{links.authorProfileUrl ? (
|
||||
<ExternalLink
|
||||
href={links.authorProfileUrl}
|
||||
icon="github"
|
||||
testId="plugin-details-author-profile"
|
||||
>
|
||||
{githubProfileLabel(links.authorProfileUrl)}
|
||||
</ExternalLink>
|
||||
) : null}
|
||||
{links.homepageUrl ? (
|
||||
<ExternalLink
|
||||
href={links.homepageUrl}
|
||||
icon="external-link"
|
||||
testId="plugin-details-author-homepage"
|
||||
>
|
||||
Homepage
|
||||
</ExternalLink>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
|
|
@ -184,6 +226,14 @@ export function PluginDetailsModal({
|
|||
</header>
|
||||
|
||||
<div className="plugin-details-modal__body">
|
||||
{examples.length > 0 ? (
|
||||
<PluginPreviewHero
|
||||
pluginId={record.id}
|
||||
pluginTitle={record.title}
|
||||
examples={examples}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{description ? (
|
||||
<Section title="About">
|
||||
<p className="plugin-details-modal__description">
|
||||
|
|
@ -496,80 +546,6 @@ export function PluginDetailsModal({
|
|||
</Section>
|
||||
) : null}
|
||||
|
||||
{examples.length > 0 ? (
|
||||
<Section
|
||||
title="Example outputs"
|
||||
count={examples.length}
|
||||
hint="Open in a new tab to see what runs from this plugin look like."
|
||||
>
|
||||
<ul className="plugin-details-modal__examples">
|
||||
{examples.map((e, idx) => {
|
||||
const base =
|
||||
e.path.split(/[\\/]/).filter(Boolean).pop() ?? `${idx}`;
|
||||
const stem = base.replace(/\.[^.]+$/, '');
|
||||
const name = e.title ?? stem;
|
||||
return (
|
||||
<li key={`${e.path}-${idx}`}>
|
||||
<a
|
||||
href={`/api/plugins/${encodeURIComponent(record.id)}/example/${encodeURIComponent(stem)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="plugin-details-modal__example-link"
|
||||
>
|
||||
<span>{name}</span>
|
||||
<Icon name="external-link" size={12} />
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{hasAuthorBlock ? (
|
||||
<Section
|
||||
title="Author"
|
||||
hint="Who maintains this plugin and where to follow them."
|
||||
>
|
||||
<div
|
||||
className="plugin-details-modal__author"
|
||||
data-testid="plugin-details-author"
|
||||
>
|
||||
<AuthorAvatar
|
||||
name={links.authorName}
|
||||
avatarUrl={links.authorAvatarUrl}
|
||||
/>
|
||||
<div className="plugin-details-modal__author-meta">
|
||||
{links.authorName ? (
|
||||
<div className="plugin-details-modal__author-name">
|
||||
{links.authorName}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="plugin-details-modal__author-links">
|
||||
{links.authorProfileUrl ? (
|
||||
<ExternalLink
|
||||
href={links.authorProfileUrl}
|
||||
icon="github"
|
||||
testId="plugin-details-author-profile"
|
||||
>
|
||||
{githubProfileLabel(links.authorProfileUrl)}
|
||||
</ExternalLink>
|
||||
) : null}
|
||||
{links.homepageUrl ? (
|
||||
<ExternalLink
|
||||
href={links.homepageUrl}
|
||||
icon="external-link"
|
||||
testId="plugin-details-author-homepage"
|
||||
>
|
||||
Homepage
|
||||
</ExternalLink>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
title="Source"
|
||||
action={
|
||||
|
|
|
|||
139
apps/web/src/components/plugin-details/PluginPreviewHero.tsx
Normal file
139
apps/web/src/components/plugin-details/PluginPreviewHero.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Hero preview surface for the PluginDetailsModal.
|
||||
//
|
||||
// Renders example outputs declared in the manifest's
|
||||
// `od.useCase.exampleOutputs[]` as a sandboxed iframe inside a
|
||||
// browser-chrome frame, with a tab pill row when more than one
|
||||
// example exists. The daemon serves each example via
|
||||
// `/api/plugins/:id/example/:name` with the §9.2 CSP +
|
||||
// `sandbox="allow-scripts"` envelope, so the preview is safe to
|
||||
// embed inline. When the plugin ships no examples we render
|
||||
// nothing (the modal hides the hero entirely).
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
export interface PluginExampleEntry {
|
||||
path: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
pluginTitle: string;
|
||||
examples: PluginExampleEntry[];
|
||||
}
|
||||
|
||||
interface NormalizedExample {
|
||||
key: string;
|
||||
name: string;
|
||||
stem: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function PluginPreviewHero({ pluginId, pluginTitle, examples }: Props) {
|
||||
const items = useMemo<NormalizedExample[]>(
|
||||
() => examples.map((e, idx) => normalize(pluginId, e, idx)),
|
||||
[pluginId, examples],
|
||||
);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(
|
||||
items[0]?.key ?? null,
|
||||
);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const active = items.find((it) => it.key === activeKey) ?? items[0]!;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="plugin-details-modal__hero"
|
||||
data-testid="plugin-details-hero"
|
||||
>
|
||||
<div className="plugin-details-modal__hero-head">
|
||||
<div className="plugin-details-modal__hero-eyebrow">
|
||||
<span className="plugin-details-modal__hero-dot" aria-hidden />
|
||||
What it produces
|
||||
</div>
|
||||
{items.length > 1 ? (
|
||||
<div
|
||||
className="plugin-details-modal__hero-tabs"
|
||||
role="tablist"
|
||||
aria-label="Example outputs"
|
||||
>
|
||||
{items.map((it) => {
|
||||
const isActive = it.key === active.key;
|
||||
return (
|
||||
<button
|
||||
key={it.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`plugin-details-modal__hero-tab${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveKey(it.key)}
|
||||
>
|
||||
{it.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="plugin-details-modal__hero-frame">
|
||||
<div className="plugin-details-modal__hero-chrome">
|
||||
<span
|
||||
className="plugin-details-modal__hero-light is-red"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="plugin-details-modal__hero-light is-yellow"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="plugin-details-modal__hero-light is-green"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="plugin-details-modal__hero-url"
|
||||
title={active.name}
|
||||
>
|
||||
<Icon name="eye" size={11} />
|
||||
<span>{active.name}</span>
|
||||
</div>
|
||||
<a
|
||||
className="plugin-details-modal__hero-popout"
|
||||
href={active.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open this example in a new tab"
|
||||
data-testid="plugin-details-hero-popout"
|
||||
>
|
||||
<Icon name="external-link" size={12} />
|
||||
<span>Open</span>
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
key={active.key}
|
||||
title={`${pluginTitle} — ${active.name}`}
|
||||
src={active.href}
|
||||
sandbox="allow-scripts"
|
||||
loading="lazy"
|
||||
className="plugin-details-modal__hero-iframe"
|
||||
data-testid="plugin-details-hero-iframe"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(
|
||||
pluginId: string,
|
||||
entry: PluginExampleEntry,
|
||||
index: number,
|
||||
): NormalizedExample {
|
||||
const segments = entry.path.split(/[\\/]/).filter(Boolean);
|
||||
const base = segments[segments.length - 1] ?? `${index}`;
|
||||
const stem = base.replace(/\.[^.]+$/, '');
|
||||
const name = entry.title ?? stem;
|
||||
const href = `/api/plugins/${encodeURIComponent(pluginId)}/example/${encodeURIComponent(stem)}`;
|
||||
return { key: `${entry.path}-${index}`, name, stem, href };
|
||||
}
|
||||
|
|
@ -515,7 +515,14 @@ a.avatar-item:visited {
|
|||
color: var(--text);
|
||||
}
|
||||
.avatar-item .avatar-item-icon {
|
||||
width: 18px; text-align: center; color: var(--text-muted); flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-item .avatar-item-meta {
|
||||
margin-left: auto;
|
||||
|
|
@ -15118,7 +15125,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
}
|
||||
.plugin-details-modal {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
max-width: 920px;
|
||||
max-height: calc(100vh - 64px);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -15220,6 +15227,224 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Promoted author byline — sits inside the modal head so the maker
|
||||
reads as a co-equal first impression alongside the plugin name.
|
||||
Larger avatar (44px) than the in-body author block had so it
|
||||
anchors the row even when no name string is provided. */
|
||||
.plugin-details-modal__byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px 6px 6px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
.plugin-details-modal__byline .plugin-details-modal__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.plugin-details-modal__byline-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-details-modal__byline-name {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-details-modal__byline-prefix {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
.plugin-details-modal__byline .plugin-details-modal__author-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
.plugin-details-modal__byline-links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.plugin-details-modal__byline-links .plugin-details-modal__ext-link {
|
||||
padding: 2px 6px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* Preview hero — promotes the manifest's `od.useCase.exampleOutputs`
|
||||
to a glanceable iframe so reviewers see what the plugin actually
|
||||
produces before reading the workflow stages. The browser-chrome
|
||||
shell (traffic lights + URL pill) signals "this is a sandboxed
|
||||
live render" without us needing extra copy. */
|
||||
.plugin-details-modal__hero {
|
||||
margin: 16px 0 4px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent-tint) 70%, transparent) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-panel) 100%);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.plugin-details-modal__hero-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plugin-details-modal__hero-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.plugin-details-modal__hero-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
.plugin-details-modal__hero-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plugin-details-modal__hero-tab {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.plugin-details-modal__hero-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.plugin-details-modal__hero-tab.is-active {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.plugin-details-modal__hero-frame {
|
||||
border-radius: var(--radius-md, 10px);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.plugin-details-modal__hero-chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-subtle) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.plugin-details-modal__hero-light {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.plugin-details-modal__hero-light.is-red { background: #ff5f57; }
|
||||
.plugin-details-modal__hero-light.is-yellow { background: #febc2e; margin-left: -2px; }
|
||||
.plugin-details-modal__hero-light.is-green { background: #28c840; margin-left: -2px; }
|
||||
.plugin-details-modal__hero-url {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
margin: 0 6px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-details-modal__hero-url > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.plugin-details-modal__hero-popout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.plugin-details-modal__hero-popout:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.plugin-details-modal__hero-iframe {
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
background: #ffffff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.plugin-details-modal__hero-iframe {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-details-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -15316,7 +15541,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
}
|
||||
.plugin-details-modal__inputs,
|
||||
.plugin-details-modal__surfaces,
|
||||
.plugin-details-modal__examples,
|
||||
.plugin-details-modal__connectors {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
|
@ -15555,22 +15779,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plugin-details-modal__example-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.plugin-details-modal__example-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.plugin-details-modal__source {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
|
|
@ -15608,11 +15816,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.plugin-details-modal__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.plugin-details-modal__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
@ -15631,26 +15834,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.plugin-details-modal__author-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugin-details-modal__author-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.plugin-details-modal__author-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.plugin-details-modal__ext-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
Loading…
Reference in a new issue