Reapply "fix(web): demote Plugins and Integrations to nav rail footer (#1806)" (#2360) (#2397)

* Reapply "fix(web): demote Plugins and Integrations to nav rail footer (#1806)" (#2360)

This reverts commit 1ab8758045.

* fixup: align EntryHelpMenu Discord URL with #2386 update

The revert of #2360 brought back EntryHelpMenu.tsx as #1806 originally
added it, with DISCORD_URL = 'https://discord.gg/BYShPgWpq'. #2386 later
rotated the Discord invite to mHAjSMV6gz, but only in the places that
existed on main at the time (EntryShell avatar dropdown + the e2e test);
EntryHelpMenu didn't exist then, so it never got updated. The e2e test
the revert reintroduced asserts the new URL, so the component must
match.
This commit is contained in:
lefarcen 2026-05-20 18:27:48 +08:00 committed by GitHub
parent 59c8d72ae4
commit 41a33aed9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 546 additions and 644 deletions

View file

@ -25,6 +25,8 @@ const ISSUES_URL = `${REPO}/issues/new`;
const PRS_URL = `${REPO}/pulls`;
const RELEASES_URL = `${REPO}/releases`;
const LATEST_RELEASE_URL = `${REPO}/releases/latest`;
const X_URL = 'https://x.com/nexudotio';
const DISCORD_URL = 'https://discord.gg/mHAjSMV6gz';
const ext = { target: '_blank', rel: 'noreferrer noopener' } as const;
@ -178,6 +180,31 @@ export function EntryHelpMenu() {
</span>
<span>{t('entry.helpDownloadDesktop')}</span>
</a>
<div className="entry-help-popover__divider" aria-hidden />
<a
className="entry-help-popover__item"
href={X_URL}
{...ext}
role="menuitem"
onClick={() => setOpen(false)}
>
<span className="entry-help-popover__icon" aria-hidden>
<Icon name="external-link" size={14} />
</span>
<span>Follow @nexudotio on X</span>
</a>
<a
className="entry-help-popover__item"
href={DISCORD_URL}
{...ext}
role="menuitem"
onClick={() => setOpen(false)}
>
<span className="entry-help-popover__icon" aria-hidden>
<Icon name="discord" size={14} />
</span>
<span>Join Discord</span>
</a>
</div>
) : null}
</div>

View file

@ -1,13 +1,16 @@
// Lovart-style left navigation rail for the entry view.
//
// Renders a narrow icon-only column. The first slot is the brand
// logo (clicking navigates to home), followed by primary
// actions (new project, home, projects, automations, plugins, design systems, integrations). A small
// help launcher sits at the bottom and opens a popover with the
// canonical "ask for help / submit a feature / what's new / download
// desktop" external links. Language switching and other account-
// scoped controls live behind the floating settings cog in the
// top-right corner of the main content.
// logo, which doubles as the Home destination: clicking it always
// navigates to home, and it carries the active `aria-current="page"`
// treatment when the home view is showing, so we do not need a
// separate Home button in the primary nav group. Primary actions
// (new project, projects, automations, design systems) follow.
// Secondary platform items (plugins, integrations) live in the footer
// section alongside the help launcher — they are accessible but visually
// de-emphasised relative to the daily-use primary destinations.
// Language switching and other account-scoped controls live behind the
// floating settings cog in the top-right corner of the main content.
import type { ReactNode } from 'react';
import { EntryHelpMenu } from './EntryHelpMenu';
@ -58,16 +61,20 @@ function NavButton({ active, ariaLabel, tooltip, onClick, testId, children }: Na
export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
const t = useT();
const brandLabel = t('app.brand');
const homeLabel = t('entry.navHome');
const isHome = view === 'home';
const logoTooltip = isHome ? brandLabel : `${brandLabel} · ${homeLabel}`;
return (
<nav className="entry-nav-rail" aria-label="Primary">
<div className="entry-nav-rail__group">
<button
type="button"
className="entry-nav-rail__logo"
className={`entry-nav-rail__logo${isHome ? ' is-active' : ''}`}
onClick={() => onViewChange('home')}
aria-label={brandLabel}
data-tooltip={brandLabel}
aria-current={isHome ? 'page' : undefined}
data-tooltip={logoTooltip}
data-testid="entry-nav-logo"
>
<img
@ -86,15 +93,6 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
>
<Icon name="plus" size={18} />
</NavButton>
<NavButton
active={view === 'home'}
ariaLabel={t('entry.navHome')}
tooltip={t('entry.navHome')}
onClick={() => onViewChange('home')}
testId="entry-nav-home"
>
<Icon name="home" size={18} />
</NavButton>
<NavButton
active={view === 'projects'}
ariaLabel={t('entry.navProjects')}
@ -113,15 +111,6 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
>
<Icon name="kanban" size={18} />
</NavButton>
<NavButton
active={view === 'plugins'}
ariaLabel={t('entry.navPlugins')}
tooltip={t('entry.navPlugins')}
onClick={() => onViewChange('plugins')}
testId="entry-nav-plugins"
>
<Icon name="grid" size={18} />
</NavButton>
<NavButton
active={view === 'design-systems'}
ariaLabel={t('entry.navDesignSystems')}
@ -131,6 +120,18 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
>
<Icon name="palette" size={18} />
</NavButton>
</div>
<div className="entry-nav-rail__footer">
<div className="entry-nav-rail__divider" role="separator" />
<NavButton
active={view === 'plugins'}
ariaLabel="Plugins"
tooltip="Plugins"
onClick={() => onViewChange('plugins')}
testId="entry-nav-plugins"
>
<Icon name="grid" size={18} />
</NavButton>
<NavButton
active={view === 'integrations'}
ariaLabel={t('entry.navIntegrations')}
@ -140,8 +141,6 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
>
<Icon name="link" size={18} />
</NavButton>
</div>
<div className="entry-nav-rail__footer">
<EntryHelpMenu />
</div>
</nav>

View file

@ -34,7 +34,7 @@ import type {
TrackingOnboardingStepIndex,
TrackingOnboardingStepName,
} from '@open-design/contracts/analytics';
import { LOCALE_LABEL, LOCALES, useI18n, useT, type Locale } from '../i18n';
import { useT } from '../i18n';
import { navigate, useRoute } from '../router';
import type {
AgentInfo,
@ -53,7 +53,6 @@ import type {
ProviderModelsResponse,
SkillSummary,
} from '../types';
import { apiProtocolLabel } from '../utils/apiProtocol';
import { formatPickAndImportFailure } from '../utils/pickAndImportError';
import { CenteredLoader } from './Loading';
import { DesignsTab } from './DesignsTab';
@ -61,7 +60,6 @@ import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { DesignSystemsTab } from './DesignSystemsTab';
import { EntryNavRail, type EntryView as EntryViewKind } from './EntryNavRail';
import { GithubStarBadge } from './GithubStarBadge';
import { formatStars, GITHUB_REPO_URL, useGithubStars } from './useGithubStars';
import { HomeView } from './HomeView';
import {
createPluginAuthoringHandoff,
@ -184,68 +182,6 @@ function defaultPluginInputsForCreate(
}
// 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
// collapsed menu item inside the settings dropdown can advertise
// the same active mode/agent/model without duplicating the
// labelling logic. Returned as a structured tuple so the menu can
// style the primary text and meta independently.
function describeModelChip(
config: AppConfig,
agents: AgentInfo[],
t: Translator,
): { mode: string; primary: string; model: string } {
const currentAgent = agents.find((a) => a.id === config.agentId) ?? null;
const currentChoice =
(config.agentId && config.agentModels?.[config.agentId]) || {};
const currentModelId =
currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null;
const currentModelLabel =
currentAgent?.models?.find((m) => m.id === currentModelId)?.label ?? null;
if (config.mode === 'daemon') {
return {
mode: t('inlineSwitcher.chipCli'),
primary: currentAgent?.name ?? t('inlineSwitcher.noAgent'),
model:
currentModelLabel && currentModelId !== 'default'
? currentModelLabel
: t('inlineSwitcher.modelDefault'),
};
}
const apiProtocol = config.apiProtocol ?? 'anthropic';
// KNOWN_PROVIDERS is consulted indirectly via apiProtocolLabel —
// looking it up here for the menu meta would diverge from the
// chip, so we keep the surface identical to InlineModelSwitcher.
return {
mode: t('inlineSwitcher.chipByok'),
primary: apiProtocolLabel(apiProtocol),
model: config.model.trim() || t('inlineSwitcher.modelDefault'),
};
}
interface Props {
skills: SkillSummary[];
@ -409,7 +345,6 @@ export function EntryShell({
onCompleteOnboarding,
}: Props) {
const t = useT();
const { locale, setLocale } = useI18n();
// Each entry sub-view (home / projects / design-systems) is its own
// URL now, so the browser back/forward buttons work and a deep link
// to /design-systems lands on that section. We derive the active
@ -417,9 +352,6 @@ export function EntryShell({
const route = useRoute();
const view: EntryViewKind = route.kind === 'home' ? route.view : 'home';
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 [newProjectInitialTab, setNewProjectInitialTab] =
useState<CreateTab>('prototype');
@ -431,18 +363,6 @@ export function EntryShell({
const [integrationTab, setIntegrationTab] = useState<IntegrationTab>(integrationInitialTab);
const [homePromptHandoff, setHomePromptHandoff] = useState<HomePromptHandoff | null>(null);
const analytics = useAnalytics();
const avatarMenuRef = useRef<HTMLDivElement | null>(null);
// Star count + active-model summary are kept in render scope so
// the dropdown's collapsed rows can mirror what the chips show
// when CSS unhides them on narrow viewports. Both surfaces are
// always rendered; only `display` flips per the media query.
const starCount = useGithubStars();
const modelSummary = useMemo(
() => describeModelChip(config, agents, t),
[config, agents, t],
);
function changeView(next: EntryViewKind) {
const navElement = navElementForView(next);
if (navElement) {
@ -599,263 +519,19 @@ export function EntryShell({
changeView('home');
}
// Dismiss the avatar dropdown on outside-click / Escape so it
// behaves like the project-view AvatarMenu (which uses the same
// shell CSS). Collapse the inline language list whenever the
// dropdown is closed, so the next open starts compact again.
useEffect(() => {
if (!avatarMenuOpen) {
setLanguageExpanded(false);
setAppearanceExpanded(false);
return;
}
const onClick = (e: MouseEvent) => {
if (!avatarMenuRef.current) return;
if (!avatarMenuRef.current.contains(e.target as Node)) {
setAvatarMenuOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setAvatarMenuOpen(false);
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [avatarMenuOpen]);
const avatarMenu = (
<div className="avatar-menu" ref={avatarMenuRef}>
<button
type="button"
className="settings-icon-btn"
onClick={() => setAvatarMenuOpen((v) => !v)}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
aria-haspopup="menu"
aria-expanded={avatarMenuOpen}
>
<Icon name="settings" size={17} />
</button>
{avatarMenuOpen ? (
<div className="avatar-popover" role="menu">
{/* Collapsed-topbar rows. Always rendered so SSR and the
client agree on the markup; CSS @media (max-width: 900px)
flips their `display` so they only show when the
matching topbar chips are themselves hidden. */}
<a
className="avatar-item avatar-item--compact-only"
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer noopener"
onClick={() => setAvatarMenuOpen(false)}
data-testid="entry-avatar-github"
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="github" size={14} />
</span>
<span>{t('entry.githubStarLabel')}</span>
<span className="avatar-item-meta">
{starCount === null ? '—' : formatStars(starCount)}
</span>
</a>
<button
type="button"
className="avatar-item avatar-item--compact-only"
onClick={() => {
setAvatarMenuOpen(false);
onOpenSettings('execution');
}}
data-testid="entry-avatar-model"
title={t('inlineSwitcher.chipTitle')}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="sparkles" size={14} />
</span>
<span className="avatar-item-stack">
<span className="avatar-item-stack__top">
{modelSummary.mode} · {modelSummary.primary}
</span>
<span className="avatar-item-stack__sub">
{modelSummary.model}
</span>
</span>
</button>
<div
className="avatar-popover__divider avatar-popover__divider--compact-only"
aria-hidden
/>
<a
className="avatar-item"
href="https://x.com/nexudotio"
target="_blank"
rel="noreferrer noopener"
onClick={() => setAvatarMenuOpen(false)}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="external-link" size={14} />
</span>
<span>Follow @nexudotio on X</span>
</a>
<a
className="avatar-item"
href="https://discord.gg/mHAjSMV6gz"
onClick={() => setAvatarMenuOpen(false)}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="discord" size={14} />
</span>
<span>Join Discord</span>
</a>
<div style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }} />
<button
type="button"
className="avatar-item"
aria-haspopup="menu"
aria-expanded={languageExpanded}
onClick={() => setLanguageExpanded((v) => !v)}
data-testid="entry-avatar-language"
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="languages" size={14} />
</span>
<span>{t('settings.language')}</span>
<span className="avatar-item-meta">{LOCALE_LABEL[locale]}</span>
<Icon
name={languageExpanded ? 'chevron-down' : 'chevron-right'}
size={11}
className="avatar-item-chevron"
/>
</button>
{languageExpanded ? (
<div className="avatar-language-list" role="group" aria-label={t('settings.language')}>
{LOCALES.map((code) => {
const active = locale === code;
return (
<button
key={code}
type="button"
role="menuitemradio"
aria-checked={active}
className={`avatar-item avatar-item--lang${active ? ' is-active' : ''}`}
onClick={() => {
setLocale(code as Locale);
setAvatarMenuOpen(false);
}}
>
<span className="avatar-item-icon" aria-hidden>
{active ? <Icon name="check" size={14} /> : null}
</span>
<span>{LOCALE_LABEL[code]}</span>
<span className="avatar-item-meta">{code}</span>
</button>
);
})}
</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"
className="avatar-item"
onClick={() => {
setAvatarMenuOpen(false);
openIntegrationTab('use-everywhere');
}}
data-testid="entry-avatar-use-everywhere"
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="hammer" size={14} />
</span>
<span>{t('entry.useEverywhereTitle')}</span>
</button>
<button
type="button"
className="avatar-item"
onClick={() => {
setAvatarMenuOpen(false);
// Toolbar→settings telemetry (CSV row "home_toolbar_click /
// element=settings") fires here rather than on the avatar
// icon: that icon now toggles the popover (a navigation
// step), and the Settings dialog only opens from this row.
// Co-locating the event with the dialog-opening side effect
// keeps the funnel honest against the prior single-click
// behavior.
trackHomeToolbarClick(analytics.track, {
page_name: 'home',
area: 'toolbar',
element: 'settings',
});
onOpenSettings();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="settings" size={14} />
</span>
<span>{t('avatar.settings')}</span>
</button>
</div>
) : null}
</div>
<button
type="button"
className="settings-icon-btn"
onClick={() => onOpenSettings()}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
>
<Icon name="settings" size={17} />
</button>
);
if (view === 'onboarding') {
return (
<div className="entry-shell entry-shell--no-header entry-shell--onboarding">

View file

@ -7,7 +7,15 @@
// composed with the recent-projects strip and plugins section
// without owning their data lifecycles.
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
ClipboardEvent as ReactClipboardEvent,
DragEvent as ReactDragEvent,
@ -314,10 +322,28 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
const openInlineInputField = openInlineInputName
? fieldByName.get(openInlineInputName) ?? null
: null;
// Surface every field, not just the ones the template references
// inline. The inline popover handles Home media slots; the form
// remains available for non-inline plugin inputs.
const remainingInputFields = pluginInputFields;
// Filter out inputs whose values are already shown (and editable
// by clicking into the textarea or the inline pill) inline in the
// prompt template. Otherwise the structured form below duplicates
// every slot pill above it — five identical labelled inputs for a
// plugin like Prototype, which made the chat box look like it had
// grown a second composer. Keep the form for plugin inputs that
// are NOT in the template (e.g. a "Run in background" toggle that
// never appears in the prompt text).
const templateFieldKeys = useMemo(() => {
if (!pluginInputTemplate) return new Set<string>();
const keys = new Set<string>();
INPUT_PLACEHOLDER_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INPUT_PLACEHOLDER_PATTERN.exec(pluginInputTemplate)) !== null) {
if (match[1]) keys.add(match[1]);
}
return keys;
}, [pluginInputTemplate]);
const remainingInputFields = useMemo(
() => pluginInputFields.filter((field) => !templateFieldKeys.has(field.name)),
[pluginInputFields, templateFieldKeys],
);
useEffect(() => {
if (selectedIndex >= visiblePickerOptions.length) setSelectedIndex(0);
@ -335,6 +361,20 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
setPromptScrollTop(inputElementRef.current?.scrollTop ?? 0);
}, [prompt, promptOverlayParts]);
// Auto-grow the prompt textarea so the chat box height tracks the
// number of lines the user has typed. We never scroll the textarea
// internally (CSS sets `overflow: hidden` and `resize: none`), so
// the only height source of truth is `scrollHeight`. Resetting to
// `auto` before measuring forces the browser to recompute against
// the actual content, otherwise shrinking the prompt would leave
// the textarea stuck at its previous taller size.
useLayoutEffect(() => {
const el = inputElementRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [prompt]);
const setInputRef = useCallback(
(node: HTMLTextAreaElement | null) => {
inputElementRef.current = node;
@ -411,17 +451,20 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
return (
<section className="home-hero" data-testid="home-hero">
<div className="home-hero__brand" aria-hidden>
<span className="home-hero__brand-mark">
<img src="/app-icon.svg" alt="" draggable={false} />
</span>
<span className="home-hero__brand-name">Open Design</span>
</div>
<h1 className="home-hero__title">{t('homeHero.title')}</h1>
<p className="home-hero__subtitle">
{t('homeHero.subtitlePrefix')} <kbd>Enter</kbd>.
{t('homeHero.subtitlePrefix')}{' '}
<kbd>Enter</kbd>.
</p>
<TypeTabBar
activeChipId={activeChipId}
pendingChipId={pendingChipId}
pendingPluginId={pendingPluginId}
pluginsLoading={pluginsLoading}
onPickChip={onPickChip}
/>
<div
className={`home-hero__input-card${dragActive ? ' is-drag-active' : ''}`}
onDragEnter={(event) => {
@ -814,7 +857,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
title={t('chat.attachAria')}
aria-label={t('chat.attachAria')}
>
<Icon name="attach" size={14} />
<Icon name="attach" size={18} />
</button>
<span className="home-hero__hint">
<kbd></kbd> {t('homeHero.toRun')} · <kbd>Shift</kbd>+<kbd></kbd> {t('homeHero.forNewLine')}
@ -829,7 +872,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
title={canSubmit ? t('homeHero.run') : t('homeHero.typeSomethingToRun')}
aria-label={t('homeHero.run')}
>
<Icon name="arrow-up" size={18} />
<Icon name="arrow-up" size={22} />
</button>
</div>
</div>
@ -840,15 +883,6 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
aria-label={t('homeHero.railAria')}
data-testid="home-hero-rail"
>
<RailGroup
group="create"
activeChipId={activeChipId}
pendingChipId={pendingChipId}
pendingPluginId={pendingPluginId}
pluginsLoading={pluginsLoading}
onPickChip={onPickChip}
/>
<span className="home-hero__rail-divider" aria-hidden />
<RailGroup
group="migrate"
activeChipId={activeChipId}
@ -1461,6 +1495,51 @@ function getPluginQueryPreview(plugin: InstalledPluginRecord): string {
return trimmed.length > 96 ? `${trimmed.slice(0, 96)}` : trimmed;
}
interface TypeTabBarProps {
activeChipId: string | null;
pendingChipId: string | null;
pendingPluginId: string | null;
pluginsLoading: boolean;
onPickChip: (chip: HomeHeroChip) => void;
}
function TypeTabBar({
activeChipId,
pendingChipId,
pendingPluginId,
pluginsLoading,
onPickChip,
}: TypeTabBarProps) {
const chips = useMemo(() => chipsForGroup('create'), []);
return (
<div className="home-hero__type-tabs" role="tablist" aria-label="Output type">
{chips.map((chip) => {
const isActive = activeChipId === chip.id;
const isPending = pendingChipId === chip.id;
const cls = ['home-hero__type-tab'];
if (isActive) cls.push('is-active');
if (isPending) cls.push('is-pending');
return (
<button
key={chip.id}
type="button"
role="tab"
className={cls.join(' ')}
data-chip-id={chip.id}
data-testid={`home-hero-rail-${chip.id}`}
onClick={() => onPickChip(chip)}
disabled={pluginsLoading || isPending || pendingPluginId !== null}
aria-selected={isActive}
title={chip.hint ?? chip.label}
>
<span>{chip.label}</span>
</button>
);
})}
</div>
);
}
interface RailGroupProps {
group: ChipGroup;
activeChipId: string | null;

View file

@ -901,11 +901,21 @@ export function HomeView({
});
return;
}
requestActivePlugin(record, undefined, {
const pluginOptions = {
projectKind: chip.action.projectKind,
chipId: chip.id,
inputs: chip.action.inputs,
});
};
// Output-type tabs (create group) are mode-selection gestures:
// switching between them should never prompt for confirmation,
// even when the input already has template text from a previous
// tab. Migrate-group chips (From Figma, etc.) still go through
// the replacement guard because they carry a meaningful prompt.
if (chip.group === 'create') {
void usePlugin(record, undefined, pluginOptions);
} else {
requestActivePlugin(record, undefined, pluginOptions);
}
return;
}
case 'create-plugin': {

View file

@ -20,10 +20,7 @@ import { useT } from '../i18n';
import type { PluginShareAction } from '../state/projects';
import { Icon } from './Icon';
import { PluginCard } from './plugins-home/PluginCard';
import {
usePluginFacets,
type FilterMode,
} from './plugins-home/usePluginFacets';
import { usePluginFacets } from './plugins-home/usePluginFacets';
import type { FacetOption } from './plugins-home/facets';
import type { PluginUseAction } from './plugins-home/useActions';
@ -75,7 +72,6 @@ export function PluginsHomeSection({
pickCategory,
pickSubcategory,
clearFacets,
hasActiveFacet,
mode,
setMode,
query,
@ -94,9 +90,9 @@ export function PluginsHomeSection({
<header className="plugins-home__head">
<div className="plugins-home__heading">
<h2 className="plugins-home__title">{title ?? t('pluginsHome.title')}</h2>
<p className="plugins-home__subtitle">
{subtitle ?? t('pluginsHome.subtitle')}
</p>
{subtitle ? (
<p className="plugins-home__subtitle">{subtitle}</p>
) : null}
</div>
<div className="plugins-home__head-tools">
{onBrowseRegistry ? (
@ -109,10 +105,6 @@ export function PluginsHomeSection({
{t('pluginsHome.browseRegistry')}
</button>
) : null}
<SearchInput value={query} onChange={setQuery} />
<span className="plugins-home__count">
{loading ? '…' : t('pluginsHome.count', { filtered: filtered.length, total: totalVisible })}
</span>
</div>
</header>
@ -124,14 +116,6 @@ export function PluginsHomeSection({
</div>
) : (
<>
<ModeRow
mode={mode}
featuredCount={featuredList.length}
totalVisible={totalVisible}
hasActiveFacet={hasActiveFacet}
onModeChange={setMode}
onClearFacets={clearFacets}
/>
<div
className="plugins-home__facets"
role="group"
@ -142,6 +126,13 @@ export function PluginsHomeSection({
selectedSlug={selection.category}
totalVisible={totalVisible}
onPick={pickCategory}
featuredCount={featuredList.length}
featuredActive={mode === 'featured'}
onToggleFeatured={() =>
setMode(mode === 'featured' ? 'all' : 'featured')
}
query={query}
onQueryChange={setQuery}
/>
{selection.category ? (
<SubcategoryRow
@ -257,73 +248,34 @@ function ContributionCard({
);
}
interface ModeRowProps {
mode: FilterMode;
featuredCount: number;
totalVisible: number;
hasActiveFacet: boolean;
onModeChange: (next: FilterMode) => void;
onClearFacets: () => void;
}
// Tiny strip above the category row: Featured override + a clear-link
// when at least one filter is active. Kept compact so the category
// bar is what the eye lands on first.
function ModeRow({
mode,
featuredCount,
totalVisible,
hasActiveFacet,
onModeChange,
onClearFacets,
}: ModeRowProps) {
const t = useT();
return (
<div className="plugins-home__mode" role="group" aria-label={t('pluginsHome.modeAria')}>
{featuredCount > 0 ? (
<button
type="button"
className={[
'plugins-home__chip',
'plugins-home__chip--featured',
mode === 'featured' ? 'is-active' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onModeChange(mode === 'featured' ? 'all' : 'featured')}
aria-pressed={mode === 'featured'}
data-testid="plugins-home-chip-featured"
>
<Icon name="star" size={11} />
<span>{t('pluginsHome.featured')}</span>
<span className="plugins-home__chip-count">{featuredCount}</span>
</button>
) : null}
<span className="plugins-home__mode-total">
{t('pluginsHome.totalInCatalog', { n: totalVisible })}
</span>
{hasActiveFacet ? (
<button
type="button"
className="plugins-home__linkbtn"
onClick={onClearFacets}
data-testid="plugins-home-clear"
>
{t('pluginsHome.clearFilters')}
</button>
) : null}
</div>
);
}
interface CategoryRowProps {
options: FacetOption[];
selectedSlug: string | null;
totalVisible: number;
onPick: (slug: string | null) => void;
featuredCount: number;
featuredActive: boolean;
onToggleFeatured: () => void;
query: string;
onQueryChange: (next: string) => void;
}
function CategoryRow({ options, selectedSlug, totalVisible, onPick }: CategoryRowProps) {
// Single combined filter bar: Featured override chip + category pills
// on the left, search field on the right. Each chip carries its own
// count, and the "All" chip doubles as a clear-filters affordance,
// so a separate `X / Y` counter and `Clear` link would just repeat
// what the chip strip already shows.
function CategoryRow({
options,
selectedSlug,
totalVisible,
onPick,
featuredCount,
featuredActive,
onToggleFeatured,
query,
onQueryChange,
}: CategoryRowProps) {
const t = useT();
if (options.length === 0) return null;
return (
@ -336,6 +288,25 @@ function CategoryRow({ options, selectedSlug, totalVisible, onPick }: CategoryRo
role="tablist"
aria-label={t('pluginsHome.categoryFilterAria')}
>
{featuredCount > 0 ? (
<button
type="button"
className={[
'plugins-home__chip',
'plugins-home__chip--featured',
featuredActive ? 'is-active' : '',
]
.filter(Boolean)
.join(' ')}
onClick={onToggleFeatured}
aria-pressed={featuredActive}
data-testid="plugins-home-chip-featured"
>
<Icon name="star" size={11} />
<span>{t('pluginsHome.featured')}</span>
<span className="plugins-home__chip-count">{featuredCount}</span>
</button>
) : null}
<CategoryPill
slug={null}
label={t('common.all')}
@ -355,6 +326,9 @@ function CategoryRow({ options, selectedSlug, totalVisible, onPick }: CategoryRo
/>
))}
</div>
<div className="plugins-home__facet-tools">
<SearchInput value={query} onChange={onQueryChange} />
</div>
</div>
);
}
@ -431,6 +405,15 @@ function CategoryPill({ slug, label, count, active, variant, testId, onPick }: C
.filter(Boolean)
.join(' ')}
onClick={() => onPick(slug)}
// Empty lanes are intentionally kept in the strip so the
// overall workflow shape (Import / Create / Export / Share /
// Deploy / Refine / Extend) is visible at a glance, and
// clicking one surfaces a "Contribute a X plugin" card. The
// `data-empty` flag drives a faded treatment in CSS so users
// can tell at a glance which chips are populated vs which
// are open-invite buckets — without that hint, "Deploy 0"
// and "Create 375" read as the same kind of control.
data-empty={count === 0 ? 'true' : 'false'}
data-testid={testId ?? `plugins-home-pill-category-${slug ?? 'all'}`}
>
<span>{displayLabel}</span>

View file

@ -6,12 +6,14 @@
// onOpen / onViewAll) so the strip can be reused later by other
// surfaces (e.g. an in-project quick-switcher pane).
import { useT } from '../i18n';
import type { Project } from '../types';
import { Icon } from './Icon';
import { useT } from '../i18n';
interface Props {
projects: Project[];
/** Retained for call-site compatibility; the strip skips rendering
* while the list is loading so we never need a loading state. */
loading?: boolean;
onOpen: (id: string) => void;
onViewAll: () => void;
@ -20,7 +22,6 @@ interface Props {
export function RecentProjectsStrip({
projects,
loading,
onOpen,
onViewAll,
limit = 6,
@ -30,6 +31,15 @@ export function RecentProjectsStrip({
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, limit);
// First-run home shouldn't reserve space for an empty "Recent
// projects" rail — the dashed empty box just adds visual noise
// above the plugin gallery. We also skip rendering during the
// load window so the section doesn't pop in and then collapse;
// the prompt hero is enough chrome on its own.
if (recent.length === 0) {
return null;
}
return (
<section className="recent-projects" data-testid="recent-projects-strip">
<header className="recent-projects__head">
@ -44,37 +54,31 @@ export function RecentProjectsStrip({
<Icon name="chevron-right" size={12} />
</button>
</header>
{loading && recent.length === 0 ? (
<div className="recent-projects__empty">{t('common.loading')}</div>
) : recent.length === 0 ? (
<div className="recent-projects__empty">{t('recentProjects.empty')}</div>
) : (
<div className="recent-projects__row" role="list">
{recent.map((project) => (
<button
key={project.id}
type="button"
role="listitem"
className="recent-projects__card"
onClick={() => onOpen(project.id)}
title={project.name}
data-project-id={project.id}
>
<div className="recent-projects__card-thumb" aria-hidden>
<span className="recent-projects__card-glyph">
{projectGlyph(project.name)}
</span>
<div className="recent-projects__row" role="list">
{recent.map((project) => (
<button
key={project.id}
type="button"
role="listitem"
className="recent-projects__card"
onClick={() => onOpen(project.id)}
title={project.name}
data-project-id={project.id}
>
<div className="recent-projects__card-thumb" aria-hidden>
<span className="recent-projects__card-glyph">
{projectGlyph(project.name)}
</span>
</div>
<div className="recent-projects__card-meta">
<div className="recent-projects__card-name">{project.name}</div>
<div className="recent-projects__card-time">
{relativeTime(project.updatedAt, t)}
</div>
<div className="recent-projects__card-meta">
<div className="recent-projects__card-name">{project.name}</div>
<div className="recent-projects__card-time">
{relativeTime(project.updatedAt, t)}
</div>
</div>
</button>
))}
</div>
)}
</div>
</button>
))}
</div>
</section>
);
}

View file

@ -70,6 +70,13 @@
width: 100%;
padding-bottom: 4px;
}
.entry-nav-rail__divider {
width: 28px;
height: 1px;
background: var(--border);
margin: 3px 0;
flex-shrink: 0;
}
/* ---------- Right-side hover tooltip (rail items only) ----------
The rail is icon-only, so each button advertises its label via a
@ -245,6 +252,13 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* The logo doubles as the Home destination; when the home view is
active it gets the same accent treatment as a primary nav button
so users still see "you are here" without a dedicated Home icon. */
.entry-nav-rail__logo.is-active {
background: var(--accent-tint);
border-color: color-mix(in srgb, var(--accent) 18%, transparent);
}
.entry-nav-rail__logo-img {
width: 100%;
height: 100%;

View file

@ -23,38 +23,6 @@
gap: 14px;
padding: 32px 0 8px;
}
.home-hero__brand {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.home-hero__brand-mark {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-panel);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 2px;
}
.home-hero__brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.home-hero__brand-name {
font-family: var(--serif);
font-weight: 600;
font-size: 16px;
color: var(--text-strong);
}
.home-hero__title {
margin: 0;
font-family: var(--serif);
@ -65,7 +33,10 @@
text-align: center;
}
.home-hero__subtitle {
margin: 0;
/* Pair tightly with the title above; the larger separation
belongs between this subtitle and the type tab bar / chat
box that follow. */
margin: -8px 0 0;
color: var(--text-muted);
font-size: 13.5px;
text-align: center;
@ -80,6 +51,85 @@
border-radius: 3px;
background: var(--bg-subtle);
}
/* ---------- Output-type tab bar ----------
Sits above the input card and lets the user pick a creation
mode (Prototype, Slide deck, Image, Video, etc.) before typing.
Folder-tab visual: the bar itself has no baseline border; the
input card's top edge serves as the baseline. The active tab
borrows the card's panel color and border so it reads as a
continuous "flap" attached to the card top this gives the
strong "tabs belong to this chat box" cue that a free-floating
underline cannot. Labels are text-only; icons were removed
because the seven labels (Prototype, Live artifact, Slide deck,
Image, Video, HyperFrames, Audio) already disambiguate at the
sizes used here and the icons added visual noise without an
information gain. */
.home-hero__type-tabs {
width: 100%;
max-width: 720px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 2px;
position: relative;
z-index: 1;
/* The parent .home-hero uses `gap: 14px`; pulling the bottom
margin back by 14px collapses that gap so the active tab can
sit flush against the card top (with its own -1px margin
covering the card's top border). The top margin restores the
breathing room above the bar after the title/subtitle pair. */
margin: 14px 0 -14px;
padding: 0 12px;
}
.home-hero__type-tab {
appearance: none;
display: inline-flex;
align-items: center;
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 8px 8px 0 0;
/* Reach 1px past the bar baseline so an active tab's bottom edge
overlays the input card's top border, hiding it for the width
of the tab and producing the folder-tab merge. */
margin-bottom: -1px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.005em;
cursor: pointer;
transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease;
}
.home-hero__type-tab:hover:not(:disabled) {
color: var(--text);
background: color-mix(in srgb, var(--bg-subtle) 70%, transparent);
}
.home-hero__type-tab.is-active {
background: var(--bg-panel);
border-color: var(--border);
/* Paint the bottom border with the panel color so it visually
erases the input card's own top border for the width of this
tab, completing the folder-tab merge. */
border-bottom-color: var(--bg-panel);
color: var(--text-strong);
font-weight: 600;
}
.home-hero__type-tab.is-active:hover:not(:disabled) {
background: var(--bg-panel);
}
.home-hero__type-tab.is-pending {
opacity: 0.65;
}
.home-hero__type-tab:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.home-hero__type-tab:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-tint);
}
.home-hero__input-card {
position: relative;
width: 100%;
@ -92,7 +142,11 @@
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
/* Card top sits exactly at the tab bar bottom (tab bar margin
cancels the parent flex gap) so the active folder-tab can
overlap the card border by 1px and read as one continuous
surface with the chat box. */
margin-top: 0;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.home-hero__input-card:focus-within {
@ -197,7 +251,14 @@
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 1.55;
/* Generous line-height so adjacent slot/mention pills do not
collide vertically when the prompt wraps. Each pill paints a
2px ring via `box-shadow`; with a tight line-height the rings
from line N and line N+1 visually merge into a single bar.
Bumping the line-height leaves ~8px of clear space between
pill rows. The textarea below mirrors this value so the
overlay and the editable text stay pixel-aligned. */
line-height: 1.85;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
@ -336,10 +397,20 @@
z-index: 1;
width: 100%;
min-height: 84px;
resize: vertical;
/* The textarea height is driven by the auto-grow effect in
HomeHero.tsx, which writes an explicit pixel height after every
keystroke. We disable the native resize grip and internal
scrollbar so users cannot accidentally shrink the box, and so
content never disappears behind a scroll the box just grows
to fit. The outer page handles overflow when the prompt is
very long. */
resize: none;
overflow: hidden;
font: inherit;
font-size: 15px;
line-height: 1.55;
/* Must match `.home-hero__prompt-highlight` so the editable
textarea and the highlight overlay wrap identically. */
line-height: 1.85;
padding: 6px 6px;
border: none;
outline: none;
@ -717,8 +788,12 @@
}
.home-hero__attach {
appearance: none;
width: 32px;
height: 32px;
/* Match the submit button size and make the paper-clip glyph
readable at typical viewing distance. The previous 32px / 14px
glyph combo rendered the icon at roughly 1112px after stroke
antialiasing and washed out against the white card. */
width: 38px;
height: 38px;
flex: 0 0 auto;
border: 1px solid var(--border);
border-radius: 50%;
@ -751,8 +826,11 @@
}
.home-hero__submit {
appearance: none;
width: 32px;
height: 32px;
/* Larger send button so the arrow glyph reads at typical viewing
distance and the primary call-to-action carries enough visual
weight against the surrounding muted controls. */
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: var(--accent);

View file

@ -53,7 +53,7 @@
position: relative;
display: inline-flex;
align-items: center;
width: 220px;
width: 200px;
max-width: 100%;
border: 1px solid var(--border);
border-radius: 999px;
@ -132,10 +132,15 @@
align-items: stretch;
}
.plugins-home__head-tools {
justify-content: space-between;
justify-content: flex-end;
}
.plugins-home__facet-tools {
width: 100%;
justify-content: flex-start;
}
.plugins-home__search {
width: 100%;
flex: 1;
min-width: 0;
}
}
.plugins-home__empty {
@ -154,22 +159,12 @@
}
/* ============================================================
Faceted filter single curated workflow bar. The Featured chip
lives in a smaller mode strip above the row so it reads as
orthogonal to the category selection it overrides.
Faceted filter a single combined bar. Featured / category
pills live on the left, the search field + result count +
clear-filters affordance live on the right. Folding the old
"mode strip + header search + category row" into one keeps the
eye on a single horizontal control surface.
============================================================ */
.plugins-home__mode {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 2px;
}
.plugins-home__mode-total {
font-size: 11.5px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.plugins-home__chip {
appearance: none;
display: inline-flex;
@ -248,9 +243,19 @@
.plugins-home__facet-row--inline {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
padding: 4px 0;
}
/* Right-aligned cluster paired with the chip strip on the same row.
Hosts the search input, the filtered-count, and a clear-filters
link when at least one facet is active. `flex: 0 0 auto` keeps
it from being squashed when the chip strip starts scrolling. */
.plugins-home__facet-tools {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.plugins-home__facet-row--sub {
padding-top: 0;
opacity: 0.92;
@ -268,7 +273,8 @@
.plugins-home__facet-row--inline .plugins-home__facet-pills::-webkit-scrollbar {
height: 0;
}
.plugins-home__facet-row--inline .plugins-home__pill {
.plugins-home__facet-row--inline .plugins-home__pill,
.plugins-home__facet-row--inline .plugins-home__chip {
flex: 0 0 auto;
scroll-snap-align: start;
}
@ -336,6 +342,33 @@
background: var(--bg-subtle);
color: var(--text-muted);
}
/*
* Empty lanes ("contribute" invites): the chip is intentionally kept
* in the strip so the workflow shape stays visible, but it's faded
* so users can tell at a glance which lanes are populated vs which
* are open-invite buckets. We keep it clickable (a click opens the
* "Contribute a X plugin" card) disabling would hide that flow.
*/
.plugins-home__pill[data-empty='true']:not(.is-active) {
opacity: 0.45;
border-style: dashed;
}
.plugins-home__pill[data-empty='true']:not(.is-active):hover {
opacity: 0.75;
}
.plugins-home__pill[data-empty='true']:not(.is-active) .plugins-home__pill-count {
color: var(--text-faint);
}
/*
* "All" pills always reflect the catalog's full size (or full lane
* size in the sub-row's case) and should not pick up the empty
* treatment even when an unrelated category happens to be zero.
*/
.plugins-home__pill--all[data-empty='true'],
.plugins-home__pill--sub-all[data-empty='true'] {
opacity: 1;
border-style: solid;
}
.plugins-home__empty--filtered {
border-style: solid;

View file

@ -329,15 +329,18 @@ describe('HomeHero plugin picker', () => {
);
// The inline pill is a read-only span so its width tracks the
// textarea text exactly — editing happens in the form below. (See
// HomeHero.tsx for why <input>/<select> at this position caused the
// overlay/textarea caret drift.)
// textarea text exactly. (See HomeHero.tsx for why <input>/<select>
// at this position caused the overlay/textarea caret drift.)
const slot = screen.getByTestId('home-hero-prompt-slot-source');
expect(slot.tagName).toBe('SPAN');
expect(slot.textContent).toBe('marketplace');
expect(slot.getAttribute('data-filled')).toBe('true');
const form = screen.getByTestId('plugin-inputs-form');
expect(form.querySelector('[data-field-name="source"]')).toBeTruthy();
// The structured inputs form below the textarea is suppressed
// when every plugin input is already referenced in the template
// — otherwise the form would render a second, identical labelled
// input for every slot pill shown inline, making the chat box
// look like it had grown a second composer.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
rerender(
<HomeHero

View file

@ -49,11 +49,9 @@ function renderHero(overrides: Partial<React.ComponentProps<typeof HomeHero>> =
describe('HomeHero intent rail', () => {
it('renders one chip per HOME_HERO_CHIPS entry', () => {
renderHero();
const rail = screen.getByTestId('home-hero-rail');
for (const chip of HOME_HERO_CHIPS) {
const node = screen.getByTestId(`home-hero-rail-${chip.id}`);
expect(node).toBeTruthy();
expect(rail.contains(node)).toBe(true);
}
});
@ -64,10 +62,10 @@ describe('HomeHero intent rail', () => {
expect(onPickChip).toHaveBeenCalledWith(findChip('image'));
});
it('marks the active chip with aria-pressed=true and the is-active class', () => {
it('marks the active output tab with aria-selected=true and the is-active class', () => {
renderHero({ activeChipId: 'video' });
const node = screen.getByTestId('home-hero-rail-video');
expect(node.getAttribute('aria-pressed')).toBe('true');
expect(node.getAttribute('aria-selected')).toBe('true');
expect(node.className).toContain('is-active');
});

View file

@ -476,14 +476,13 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-prompt-slot-artifactKind')).toBeTruthy();
expect(screen.getByTestId('home-hero-prompt-slot-designSystem')).toBeTruthy();
expect(screen.getByTestId('home-hero-prompt-slot-template')).toBeTruthy();
// Inline pills are read-only; the editable controls live in the
// PluginInputsForm below so caret positions in the textarea no
// longer drift away from where the user clicked in the overlay.
expect(screen.getByTestId('plugin-inputs-form')).toBeTruthy();
// Template-backed inputs are represented inline in the prompt, so
// the structured form below should not duplicate the same fields.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
expect(screen.queryByRole('alert')).toBeNull();
});
it('confirms before an explicit plugin use replaces an existing prompt', async () => {
it('applies output-type chips immediately when replacing an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
@ -518,15 +517,11 @@ describe('HomeView prompt handoff', () => {
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
expect(await screen.findByRole('dialog', { name: /replace current prompt/i })).toBeTruthy();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
));
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});
it('appends a plugin-use query handoff without replacing an existing prompt', async () => {
@ -675,28 +670,18 @@ describe('HomeView prompt handoff', () => {
expect.anything(),
));
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
const goalInput = await screen.findByLabelText(/plugin goal/i);
fireEvent.change(goalInput, {
target: { value: 'turn support transcripts into triaged GitHub issues' },
});
await waitFor(() => {
expect(input.value).toContain('turn support transcripts into triaged GitHub issues');
});
const rewrittenGoal = 'catalog internal research notes into a reusable knowledge workflow';
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
fireEvent.change(input, {
target: {
value: input.value.replace(
'turn support transcripts into triaged GitHub issues',
PLUGIN_AUTHORING_DEFAULT_GOAL,
rewrittenGoal,
),
},
});
await waitFor(() => {
expect((screen.getByLabelText(/plugin goal/i) as HTMLInputElement).value)
.toBe(rewrittenGoal);
expect(input.value).toContain(rewrittenGoal);
});
fireEvent.click(screen.getByTestId('home-hero-submit'));

View file

@ -223,7 +223,9 @@ describe('PluginsView', () => {
.map((item) => item.getAttribute('data-plugin-id'))
.sort(),
).toEqual(['create-plugin', 'import-plugin']);
expect(screen.getByText('2 of 2')).toBeTruthy();
const summary = screen.getByLabelText('Plugin summary');
expect(within(summary).getByText('2')).toBeTruthy();
expect(within(summary).getByText('Installed')).toBeTruthy();
});
it('hands installed plugin Use actions to the host shell', async () => {

View file

@ -66,18 +66,19 @@ test.beforeEach(async ({ page }) => {
test('home loads with the primary entry controls', async ({ page }) => {
await gotoEntryHome(page);
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-logo')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-home')).toHaveCount(0);
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
});
test('settings menu is reachable from home', async ({ page }) => {
test('settings dialog is reachable from home', async ({ page }) => {
await gotoEntryHome(page);
await page.locator('.avatar-menu .settings-icon-btn').click();
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
await expect(settingsMenu).toBeVisible();
await expect(settingsMenu.getByRole('button', { name: /^settings$/i })).toBeVisible();
await page.getByRole('button', { name: 'Open settings' }).click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await expect(settingsDialog.getByRole('heading', { name: 'Execution mode' })).toBeVisible();
});
test('prototype project creation reaches the workspace shell', async ({ page }) => {

View file

@ -121,12 +121,23 @@ test.beforeEach(async ({ page }) => {
});
});
test('entry chrome settings menu opens with brand header and no pet rail', async ({ page }) => {
test('entry chrome settings dialog opens with brand header and no pet rail', async ({ page }) => {
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.continue();
});
await gotoEntryHome(page);
await expect(page.getByTestId('entry-star-badge')).toBeVisible();
await expect(page.getByTestId('entry-use-everywhere-button')).toBeVisible();
await expect(page.getByTestId('entry-nav-logo')).toBeVisible();
await expect(page.getByTestId('recent-projects-strip')).toBeVisible();
// First-run home (no projects mocked) should NOT render the
// recent-projects rail — it used to render an empty dashed box
// that was just visual noise above the plugin gallery.
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
await expect(page.locator('.entry-nav-rail')).toBeVisible();
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.locator('.entry-brand')).toHaveCount(0);
@ -136,19 +147,23 @@ test('entry chrome settings menu opens with brand header and no pet rail', async
// entry layout.
await expect(page.locator('.pet-rail')).toHaveCount(0);
await page.locator('.avatar-menu .settings-icon-btn').click();
const settingsMenu = page.locator('.avatar-popover[role="menu"]');
await expect(settingsMenu).toBeVisible();
await expect(settingsMenu.getByRole('button', { name: /^settings$/i })).toBeVisible();
await expect(settingsMenu.getByRole('button', { name: /hide pet picker/i })).toHaveCount(0);
await expect(settingsMenu.getByRole('button', { name: /show pet picker/i })).toHaveCount(0);
await page.getByRole('button', { name: 'Open settings' }).click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await expect(settingsDialog.getByRole('heading', { name: 'Execution mode' })).toBeVisible();
await expect(settingsDialog.getByRole('button', { name: /hide pet picker/i })).toHaveCount(0);
await expect(settingsDialog.getByRole('button', { name: /show pet picker/i })).toHaveCount(0);
});
test('entry top navigation matches the current home tab structure', async ({ page }) => {
await gotoEntryHome(page);
// The brand logo doubles as the Home destination; there is no
// separate Home button in the primary nav group. The logo carries
// the active `aria-current="page"` treatment when home is showing.
await expect(page.getByTestId('entry-nav-logo')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-home')).toHaveCount(0);
await expect(page.getByTestId('entry-nav-new-project')).toBeVisible();
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-projects')).toBeVisible();
await expect(page.getByTestId('entry-nav-tasks')).toBeVisible();
await expect(page.getByTestId('entry-nav-plugins')).toBeVisible();
@ -178,9 +193,9 @@ test('home view exposes the redesigned hero, recent projects, starters, and moda
await page.keyboard.press('Escape');
await expect(page.getByTestId('new-project-modal')).toHaveCount(0);
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page');
await expect(page.getByTestId('entry-nav-logo')).toHaveAttribute('aria-current', 'page');
await page.getByTestId('recent-projects-view-all').click();
await page.getByTestId('entry-nav-projects').click();
await expect(page).toHaveURL(/\/projects$/);
await expect(page.getByTestId('entry-nav-projects')).toHaveAttribute('aria-current', 'page');
});
@ -389,68 +404,62 @@ test('entry execution pill opens the Local CLI and BYOK switcher from Home', asy
await expect(page.getByRole('tab', { name: LOCAL_CLI_LABEL })).toBeVisible();
});
test('entry avatar menu exposes homepage quick actions and routes Use everywhere', async ({ page }) => {
test('entry help menu exposes community links and topbar routes Use everywhere', async ({ page }) => {
await gotoEntryHome(page);
await page.locator('.avatar-menu .settings-icon-btn').click();
const menu = page.locator('.avatar-popover[role="menu"]');
await page.getByTestId('entry-help-trigger').click();
const menu = page.locator('.entry-help-popover[role="menu"]');
await expect(menu).toBeVisible();
await expect(menu.getByRole('link', { name: /Follow @nexudotio on X/i })).toHaveAttribute(
await expect(menu.getByRole('menuitem', { name: /Follow @nexudotio on X/i })).toHaveAttribute(
'href',
'https://x.com/nexudotio',
);
await expect(menu.getByRole('link', { name: /Join Discord/i })).toHaveAttribute(
await expect(menu.getByRole('menuitem', { name: /Join Discord/i })).toHaveAttribute(
'href',
'https://discord.gg/mHAjSMV6gz',
);
await menu.getByTestId('entry-avatar-language').click();
await expect(menu.getByRole('group', { name: /Language/i })).toBeVisible();
await menu.getByTestId('entry-avatar-appearance').click();
await expect(menu.getByRole('group', { name: /Appearance/i })).toBeVisible();
await menu.getByTestId('entry-avatar-use-everywhere').click();
await page.getByTestId('entry-use-everywhere-button').click();
await expect(page.getByRole('heading', { name: 'Integrations' })).toBeVisible();
await expect(page.getByTestId('integrations-tab-use-everywhere')).toHaveAttribute(
'aria-selected',
'true',
);
await page.getByTestId('entry-nav-home').click();
await page.getByTestId('entry-nav-logo').click();
await expect(page.getByTestId('home-hero')).toBeVisible();
await page.locator('.avatar-menu .settings-icon-btn').click();
await page.getByTestId('entry-help-trigger').click();
await expect(menu).toBeVisible();
await page.keyboard.press('Escape');
await expect(menu).toHaveCount(0);
});
test('home topbar overlays are mutually exclusive and close on outside click or Escape', async ({ page }) => {
test('home topbar overlays close on outside click, Escape, and Settings open', async ({ page }) => {
await gotoEntryHome(page);
const pill = page.getByTestId('inline-model-switcher-chip');
const executionPopover = page.getByTestId('inline-model-switcher-popover');
const avatarButton = page.locator('.avatar-menu .settings-icon-btn');
const avatarMenu = page.locator('.avatar-popover[role="menu"]');
const settingsButton = page.getByRole('button', { name: 'Open settings' });
await pill.click();
await expect(executionPopover).toBeVisible();
await avatarButton.click();
await expect(avatarMenu).toBeVisible();
await settingsButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(executionPopover).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await pill.click();
await expect(executionPopover).toBeVisible();
await expect(avatarMenu).toHaveCount(0);
await page.getByTestId('home-hero').click();
await expect(executionPopover).toHaveCount(0);
await avatarButton.click();
await expect(avatarMenu).toBeVisible();
await pill.click();
await expect(executionPopover).toBeVisible();
await page.keyboard.press('Escape');
await expect(avatarMenu).toHaveCount(0);
await expect(executionPopover).toHaveCount(0);
});
test('entry execution pill remains available across secondary entry pages', async ({ page }) => {
@ -507,9 +516,7 @@ test('home recent projects shows the empty state when the project list is empty'
});
await gotoEntryHome(page);
await expect(page.getByTestId('recent-projects-strip')).toContainText(
'No projects yet — type a prompt to start one.',
);
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
});
test('home recent projects sorts newest first and caps the strip at six cards', async ({ page }) => {
@ -556,7 +563,7 @@ test('home starters can browse registry and use a starter query from Home', asyn
await expect(page).toHaveURL(/\/plugins$/);
await expect(page.getByTestId('entry-nav-plugins')).toHaveAttribute('aria-current', 'page');
await page.getByTestId('entry-nav-home').click();
await page.getByTestId('entry-nav-logo').click();
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('plugins-home-use-menu-localized-plugin')).toBeVisible();
await page.getByTestId('plugins-home-use-menu-localized-plugin').click({ force: true });
@ -591,16 +598,15 @@ test('home starters search and facet filters narrow the visible gallery', async
await gotoEntryHome(page);
await expect(page.getByTestId('plugins-home-chip-featured')).toBeVisible();
await expect(page.locator('.plugins-home__mode-total')).toContainText('4 in catalog');
await expect(page.getByTestId('plugins-home-pill-category-all')).toContainText('4');
await page.getByTestId('plugins-home-pill-category-import').click();
await expect(page.locator('[data-plugin-id="figma-importer"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toHaveCount(0);
await expect(page.locator('[data-plugin-id="deck-writer"]')).toHaveCount(0);
await expect(page.getByTestId('plugins-home-clear')).toBeVisible();
await page.getByTestId('plugins-home-clear').click();
await page.getByTestId('plugins-home-pill-category-all').click();
await expect(page.locator('[data-plugin-id="figma-importer"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="hyperframes-video"]')).toBeVisible();
@ -636,7 +642,7 @@ test('home starters search can enter a no-results state and recover with clear',
await expect(page.getByTestId('plugins-home-section')).toContainText(
'No plugins match the current filters.',
);
await page.getByTestId('plugins-home-clear').click();
await page.getByRole('button', { name: /Clear filters/i }).click();
await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible();
await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible();
});
@ -893,7 +899,11 @@ test('home starters Use with query hydrates the prompt and keeps plugin context
const input = page.getByTestId('home-hero-input');
await expect(input).toHaveValue('');
await page.getByTestId('plugins-home-use-menu-localized-plugin').click({ force: true });
const starterCard = page.locator('[data-plugin-id="localized-plugin"]').first();
await starterCard.scrollIntoViewIfNeeded();
await starterCard.hover();
await expect(page.getByTestId('plugins-home-use-menu-localized-plugin')).toBeVisible();
await page.getByTestId('plugins-home-use-menu-localized-plugin').click();
await page.getByTestId('plugins-home-use-with-query-localized-plugin').click();
await expect(page.getByTestId('home-hero-context-plugin-localized-plugin')).toBeVisible();
await expect(input).toHaveValue('Make a design systems brief.');