+ 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 = (
-
- ) : 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 ? (
-
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 (
-
- );
-}
-
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 ? (
-