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:
pftom 2026-05-12 12:13:57 +08:00
parent 9825b3ba1f
commit 583bcaf64f
6 changed files with 527 additions and 118 deletions

View file

@ -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}

View file

@ -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"

View file

@ -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 } : {})}

View file

@ -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={

View 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 };
}

View file

@ -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;