mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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:
parent
59c8d72ae4
commit
41a33aed9e
16 changed files with 546 additions and 644 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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 11–12px 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
Loading…
Reference in a new issue