From b021303191719e8e063b8ef2dcec232994ffa453 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Wed, 20 May 2026 16:41:23 +0800 Subject: [PATCH 1/2] Reapply "fix(web): demote Plugins and Integrations to nav rail footer (#1806)" (#2360) This reverts commit 1ab87580456f6ff0b98f1db08c28849958342fa3. --- apps/web/src/components/EntryHelpMenu.tsx | 27 ++ apps/web/src/components/EntryNavRail.tsx | 57 ++- apps/web/src/components/EntryShell.tsx | 346 +----------------- apps/web/src/components/HomeHero.tsx | 125 +++++-- apps/web/src/components/HomeView.tsx | 14 +- .../web/src/components/PluginsHomeSection.tsx | 143 ++++---- .../src/components/RecentProjectsStrip.tsx | 68 ++-- apps/web/src/styles/home/entry-layout.css | 14 + apps/web/src/styles/home/home-hero.css | 160 +++++--- apps/web/src/styles/home/plugins-home.css | 73 +++- .../HomeHero.plugin-picker.test.tsx | 13 +- .../tests/components/HomeHero.rail.test.tsx | 6 +- .../components/HomeView.prefill.test.tsx | 31 +- .../web/tests/components/PluginsView.test.tsx | 4 +- e2e/ui/critical-smoke.test.ts | 13 +- e2e/ui/entry-chrome-flows.test.ts | 96 ++--- 16 files changed, 546 insertions(+), 644 deletions(-) diff --git a/apps/web/src/components/EntryHelpMenu.tsx b/apps/web/src/components/EntryHelpMenu.tsx index 606cc5f76e..12fd1e8e27 100644 --- a/apps/web/src/components/EntryHelpMenu.tsx +++ b/apps/web/src/components/EntryHelpMenu.tsx @@ -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/BYShPgWpq'; const ext = { target: '_blank', rel: 'noreferrer noopener' } as const; @@ -178,6 +180,31 @@ export function EntryHelpMenu() { {t('entry.helpDownloadDesktop')} +
+ setOpen(false)} + > + + + + Follow @nexudotio on X + + setOpen(false)} + > + + + + Join Discord +
) : null} diff --git a/apps/web/src/components/EntryNavRail.tsx b/apps/web/src/components/EntryNavRail.tsx index dfd1d6e059..a94978fcd4 100644 --- a/apps/web/src/components/EntryNavRail.tsx +++ b/apps/web/src/components/EntryNavRail.tsx @@ -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 ( diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 4a967dbaf6..5883265188 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -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, @@ -142,68 +140,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 = { - system: 'settings.themeSystem', - light: 'settings.themeLight', - dark: 'settings.themeDark', -}; - -type Translator = ReturnType; - -// 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[]; @@ -367,7 +303,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 @@ -375,9 +310,6 @@ export function EntryShell({ const route = useRoute(); const view: EntryViewKind = route.kind === 'home' ? route.view : 'home'; const [previewSystemId, setPreviewSystemId] = useState(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('prototype'); @@ -389,18 +321,6 @@ export function EntryShell({ const [integrationTab, setIntegrationTab] = useState(integrationInitialTab); const [homePromptHandoff, setHomePromptHandoff] = useState(null); const analytics = useAnalytics(); - const avatarMenuRef = useRef(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) { @@ -557,263 +477,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 = ( -
- - {avatarMenuOpen ? ( -
- {/* 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. */} - setAvatarMenuOpen(false)} - data-testid="entry-avatar-github" - > - - - - {t('entry.githubStarLabel')} - - {starCount === null ? '—' : formatStars(starCount)} - - - -
- setAvatarMenuOpen(false)} - > - - - - Follow @nexudotio on X - - setAvatarMenuOpen(false)} - > - - - - Join Discord - -
- - {languageExpanded ? ( -
- {LOCALES.map((code) => { - const active = locale === code; - return ( - - ); - })} -
- ) : 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. */} - - {appearanceExpanded ? ( -
- {APPEARANCE_THEMES.map(({ value, labelKey }) => { - const active = (config.theme ?? 'system') === value; - return ( - - ); - })} -
- ) : null} -
- - -
- ) : null} -
+ ); + if (view === 'onboarding') { return (
diff --git a/apps/web/src/components/HomeHero.tsx b/apps/web/src/components/HomeHero.tsx index 8e0d23597b..8023d67b68 100644 --- a/apps/web/src/components/HomeHero.tsx +++ b/apps/web/src/components/HomeHero.tsx @@ -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(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(); + const keys = new Set(); + 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(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(function HomeHero return (
-
- - - - Open Design -

{t('homeHero.title')}

- {t('homeHero.subtitlePrefix')} Enter. + {t('homeHero.subtitlePrefix')}{' '} + Enter.

+ +
{ @@ -814,7 +857,7 @@ export const HomeHero = forwardRef(function HomeHero title={t('chat.attachAria')} aria-label={t('chat.attachAria')} > - + {t('homeHero.toRun')} · Shift+ {t('homeHero.forNewLine')} @@ -829,7 +872,7 @@ export const HomeHero = forwardRef(function HomeHero title={canSubmit ? t('homeHero.run') : t('homeHero.typeSomethingToRun')} aria-label={t('homeHero.run')} > - +
@@ -840,15 +883,6 @@ export const HomeHero = forwardRef(function HomeHero aria-label={t('homeHero.railAria')} data-testid="home-hero-rail" > - - 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 ( +
+ {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 ( + + ); + })} +
+ ); +} + interface RailGroupProps { group: ChipGroup; activeChipId: string | null; diff --git a/apps/web/src/components/HomeView.tsx b/apps/web/src/components/HomeView.tsx index c80b2be96c..7eaec8dc4b 100644 --- a/apps/web/src/components/HomeView.tsx +++ b/apps/web/src/components/HomeView.tsx @@ -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': { diff --git a/apps/web/src/components/PluginsHomeSection.tsx b/apps/web/src/components/PluginsHomeSection.tsx index f5c89c6616..b4562d75f6 100644 --- a/apps/web/src/components/PluginsHomeSection.tsx +++ b/apps/web/src/components/PluginsHomeSection.tsx @@ -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({

{title ?? t('pluginsHome.title')}

-

- {subtitle ?? t('pluginsHome.subtitle')} -

+ {subtitle ? ( +

{subtitle}

+ ) : null}
{onBrowseRegistry ? ( @@ -109,10 +105,6 @@ export function PluginsHomeSection({ {t('pluginsHome.browseRegistry')} ) : null} - - - {loading ? '…' : t('pluginsHome.count', { filtered: filtered.length, total: totalVisible })} -
@@ -124,14 +116,6 @@ export function PluginsHomeSection({
) : ( <> -
+ setMode(mode === 'featured' ? 'all' : 'featured') + } + query={query} + onQueryChange={setQuery} /> {selection.category ? ( 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 ( -
- {featuredCount > 0 ? ( - - ) : null} - - {t('pluginsHome.totalInCatalog', { n: totalVisible })} - - {hasActiveFacet ? ( - - ) : null} -
- ); -} - 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 ? ( + + ) : null} ))}
+
+ +
); } @@ -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'}`} > {displayLabel} diff --git a/apps/web/src/components/RecentProjectsStrip.tsx b/apps/web/src/components/RecentProjectsStrip.tsx index 13a2b12829..6246ea662d 100644 --- a/apps/web/src/components/RecentProjectsStrip.tsx +++ b/apps/web/src/components/RecentProjectsStrip.tsx @@ -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 (
@@ -44,37 +54,31 @@ export function RecentProjectsStrip({
- {loading && recent.length === 0 ? ( -
{t('common.loading')}
- ) : recent.length === 0 ? ( -
{t('recentProjects.empty')}
- ) : ( -
- {recent.map((project) => ( - - ))} -
- )} +
+ + ))} + ); } diff --git a/apps/web/src/styles/home/entry-layout.css b/apps/web/src/styles/home/entry-layout.css index 3758dfc077..e0585504f3 100644 --- a/apps/web/src/styles/home/entry-layout.css +++ b/apps/web/src/styles/home/entry-layout.css @@ -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%; diff --git a/apps/web/src/styles/home/home-hero.css b/apps/web/src/styles/home/home-hero.css index ada750a3a8..6c927baf5d 100644 --- a/apps/web/src/styles/home/home-hero.css +++ b/apps/web/src/styles/home/home-hero.css @@ -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); diff --git a/apps/web/src/styles/home/plugins-home.css b/apps/web/src/styles/home/plugins-home.css index 53c554030b..0463360005 100644 --- a/apps/web/src/styles/home/plugins-home.css +++ b/apps/web/src/styles/home/plugins-home.css @@ -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__search { + .plugins-home__facet-tools { width: 100%; + justify-content: flex-start; + } + .plugins-home__search { + 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; diff --git a/apps/web/tests/components/HomeHero.plugin-picker.test.tsx b/apps/web/tests/components/HomeHero.plugin-picker.test.tsx index 80f3e56ea8..12e7c4fd36 100644 --- a/apps/web/tests/components/HomeHero.plugin-picker.test.tsx +++ b/apps/web/tests/components/HomeHero.plugin-picker.test.tsx @@ -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 //