From c9a48fa23f6cbafee7cfaaf7922e1550e9b7e9ae Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 24 Aug 2026 12:26:19 +0200 Subject: [PATCH 1/2] fix: polish login backdrop and wordmark (#6687) --- .../src/onboarding-studio.module.css | 7 +++++- .../onboarding/src/onboarding-wordmark.tsx | 25 +++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index dd92073d7..f8c8ecd67 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -31,11 +31,14 @@ } .marqueeRail { + --marquee-horizontal-mask: linear-gradient(black, black); position: absolute; top: -14rem; bottom: -14rem; width: clamp(22rem, 26vw, 32rem); - mask-image: linear-gradient(to bottom, transparent, black 10%, black 90%, transparent); + mask-image: + linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), var(--marquee-horizontal-mask); + mask-composite: intersect; } .marqueeRail > * { @@ -46,12 +49,14 @@ .marqueeRailLeft { left: -7rem; --integration-marquee-accent: var(--studio-glow-color); + --marquee-horizontal-mask: linear-gradient(to right, black 55%, transparent); } .marqueeRailRight { right: -7rem; top: -22rem; --integration-marquee-accent: var(--studio-secondary-glow-color); + --marquee-horizontal-mask: linear-gradient(to left, black 55%, transparent); } .shell { diff --git a/packages/onboarding/src/onboarding-wordmark.tsx b/packages/onboarding/src/onboarding-wordmark.tsx index 69ea04631..550cff21b 100644 --- a/packages/onboarding/src/onboarding-wordmark.tsx +++ b/packages/onboarding/src/onboarding-wordmark.tsx @@ -1,8 +1,7 @@ "use client"; -import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react"; +import type { CSSProperties } from "react"; import { useState } from "react"; -import { useComputedColorScheme } from "@mantine/core"; import confetti from "canvas-confetti"; import HomarrWordmarkLight from "./homarr-wordmark-light"; @@ -15,21 +14,23 @@ interface OnboardingWordmarkProps { large?: boolean; } +const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg"; + export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => { const [celebrating, setCelebrating] = useState(false); - const colorScheme = useComputedColorScheme("light"); const sounds = useOnboardingSounds(); + const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`; const colors = { "--homarr-wordmark-primary": primaryColor ?? "#F92424", "--homarr-wordmark-secondary": secondaryColor ?? "#FA5252", - "--homarr-wordmark-foreground": colorScheme === "light" ? "#1A1B1E" : "#FEFDFD", + "--homarr-wordmark-foreground": "#FEFDFD", } as CSSProperties; - const celebrate = (event: ReactMouseEvent) => { + const celebrate = (logoElement: Element) => { sounds.hover(); - const logo = event.currentTarget.getBoundingClientRect(); + const logo = logoElement.getBoundingClientRect(); const originY = (logo.top + logo.height / 2) / window.innerHeight; - const colors = primaryColor && secondaryColor ? [primaryColor, secondaryColor] : undefined; + const confettiColors = primaryColor && secondaryColor ? [primaryColor, secondaryColor] : undefined; if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setCelebrating(true); for (const { angle, originX } of [ @@ -45,19 +46,23 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false ticks: 90, scalar: 0.8, origin: { x: originX / window.innerWidth, y: originY }, - colors, + colors: confettiColors, disableForReducedMotion: true, }); } }; + if (primaryColor === undefined && secondaryColor === undefined) { + return Homarr; + } + return ( celebrate(event.currentTarget)} onAnimationEnd={() => setCelebrating(false)} /> ); From 97d8390b2010e19935d29042af3348507932145b Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 24 Aug 2026 15:40:09 +0200 Subject: [PATCH 2/2] feat: rebuild command menu (#6692) --- .../advanced/keyboard-shortcuts/index.mdx | 17 +- apps/docs/docs/management/assistant.mdx | 2 +- .../docs/management/search-engines/index.mdx | 8 +- .../app/[locale]/boards/(content)/_client.tsx | 2 + .../assistant/assistant-provider.tsx | 1 + .../assistant-spotlight-placeholder.ts | 1 + .../board/board-apps-spotlight-registrar.tsx | 70 ++++ .../src/components/board/board-switcher.tsx | 16 + .../layout/header/lazy-spotlight.tsx | 30 +- .../src/components/layout/header/search.tsx | 4 +- packages/api/src/router/board.ts | 155 ++++---- .../search-engine/search-engine-router.ts | 22 ++ packages/api/src/test/mcp.spec.ts | 2 + packages/definitions/src/hotkeys.ts | 3 + .../src/components/actions/group-actions.tsx | 62 +-- .../actions/groups/action-group.tsx | 6 +- .../actions/items/group-action-item.tsx | 9 +- .../spotlight/src/components/mode-rail.tsx | 80 ++++ .../spotlight/src/components/no-results.tsx | 4 +- .../src/components/spotlight.module.css | 106 ++++++ .../spotlight/src/components/spotlight.tsx | 275 +++++++++----- packages/spotlight/src/index.ts | 1 + packages/spotlight/src/lib/catalog.ts | 96 +++++ packages/spotlight/src/lib/group.ts | 32 +- packages/spotlight/src/lib/interaction.ts | 4 +- packages/spotlight/src/lib/mode.ts | 7 +- packages/spotlight/src/lib/remote-query.ts | 46 +++ .../apps-search-group.tsx | 18 +- .../boards-search-group.tsx | 11 +- .../src/modes/app-integration-board/index.tsx | 15 +- .../integrations-search-group.tsx | 10 +- .../spotlight/src/modes/assistant/index.tsx | 10 + .../preferences/settings-children.tsx | 32 +- .../modes/command/context-specific-group.tsx | 1 + .../src/modes/command/global-group.tsx | 3 +- .../spotlight/src/modes/command/index.tsx | 6 +- .../spotlight/src/modes/external/index.tsx | 4 +- .../external/search-engines-search-group.tsx | 353 ++++++++++-------- .../src/modes/help/home-empty-groups.tsx | 138 ------- .../src/modes/home/context-specific-group.tsx | 98 +++-- packages/spotlight/src/modes/home/context.tsx | 20 +- .../modes/home/home-search-engine-group.tsx | 165 ++++---- packages/spotlight/src/modes/home/index.tsx | 38 +- packages/spotlight/src/modes/index.tsx | 16 +- packages/spotlight/src/modes/media/index.tsx | 4 +- .../src/modes/media/media-fallback-group.tsx | 87 +++++ .../media/media-request-search-group.tsx | 52 ++- packages/spotlight/src/modes/page/index.tsx | 8 - .../src/modes/page/pages-search-group.tsx | 1 + .../src/modes/preferences/groups.tsx | 92 +---- .../spotlight/src/modes/preferences/index.tsx | 10 + .../modes/user-group/groups-search-group.tsx | 5 +- .../spotlight/src/modes/user-group/index.tsx | 12 +- .../modes/user-group/users-search-group.tsx | 5 +- packages/spotlight/src/open.spec.ts | 18 +- packages/spotlight/src/open.ts | 46 ++- packages/translation/src/lang/en.json | 56 ++- packages/widgets/src/app/component.tsx | 21 -- .../src/bookmarks/bookmarks-widget.tsx | 6 +- 59 files changed, 1559 insertions(+), 863 deletions(-) create mode 100644 apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx create mode 100644 packages/spotlight/src/components/mode-rail.tsx create mode 100644 packages/spotlight/src/components/spotlight.module.css create mode 100644 packages/spotlight/src/lib/catalog.ts create mode 100644 packages/spotlight/src/lib/remote-query.ts create mode 100644 packages/spotlight/src/modes/assistant/index.tsx delete mode 100644 packages/spotlight/src/modes/help/home-empty-groups.tsx create mode 100644 packages/spotlight/src/modes/media/media-fallback-group.tsx delete mode 100644 packages/spotlight/src/modes/page/index.tsx create mode 100644 packages/spotlight/src/modes/preferences/index.tsx diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 07765cf11..4c3bca3fe 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -8,13 +8,28 @@ Homarr has the following keyboard shortcuts: | Shortcut | Description | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `MOD` + `K` | Opens the search bar | +| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences | +| `MOD` + `P` | Opens app search | +| `MOD` + `/` | Opens Homarr Assistant inside search | +| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search | +| `Shift` + `A` | Opens the full Homarr Assistant | | `MOD` + `J` | Toggles Dark Mode | | `MOD` + `E` | Toggles Edit Mode | | Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | | Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed | | `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | +## Search and commands + +Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards, +integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear +before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without +leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover +DuckDuckGo bangs; bang discovery is excluded from ordinary Search results. + +Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow +the same query when needed. Media request search is available only when a supported Seerr integration is configured. + ## Widget advanced view Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index aec1bde4e..3bda33bca 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -38,7 +38,7 @@ host gateway address. ## Use it - Select **Assistant** in the user menu or press `Shift + A`. -- Ask from `Cmd/Ctrl + K`. +- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback. - Add the [Assistant widget](../widgets/assistant) to a board. The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to diff --git a/apps/docs/docs/management/search-engines/index.mdx b/apps/docs/docs/management/search-engines/index.mdx index 186853d8c..0afe333d4 100644 --- a/apps/docs/docs/management/search-engines/index.mdx +++ b/apps/docs/docs/management/search-engines/index.mdx @@ -7,9 +7,13 @@ tags: # Search Engines -Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`. +Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`. The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. +The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover +engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an +ordinary search term never expands into similarly named bang providers. + You can add any search engine that has a search URL that accepts a search term as a parameter. ## Create search engine @@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover. Prefix that is used to choose the search engine in the popover using the keyboard. It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia). +Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover +additional bang providers. ### Configuration diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 395178901..5ecd8be66 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client"; import { FloatingTip } from "@homarr/ui"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; +import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar"; import { BoardEmptyState } from "~/components/board/board-empty-state"; import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { @@ -79,6 +80,7 @@ export const ClientBoard = () => { + diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index a7f7f6440..7a43ab1aa 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { : t("busy.description"), unavailable: !canSend, alwaysVisible: true, + placement: "fallback" as const, interaction: (query: string) => createAssistantPromptInteraction({ sendPrompt, diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts index f74aea66d..1bc428f78 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts @@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) => description, unavailable: true, alwaysVisible: true, + placement: "fallback" as const, interaction: (_query: string) => ({ type: "none" as const }), }), [description, t], diff --git a/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx new file mode 100644 index 000000000..721301860 --- /dev/null +++ b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { getSafeAppHref } from "@homarr/common"; +import type { ContextSpecificItem } from "@homarr/spotlight"; +import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; +import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open"; + +export const BoardAppsSpotlightRegistrar = () => { + const board = useRequiredBoard(); + const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen()); + + useEffect(() => { + if (isSpotlightActivated) return; + + const activateSpotlightQueries = () => setIsSpotlightActivated(true); + window.addEventListener(spotlightOpenEvent, activateSpotlightQueries); + + if (hasPendingSpotlightOpen()) activateSpotlightQueries(); + + return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries); + }, [isSpotlightActivated]); + + const openInNewTabByAppId = useMemo(() => { + const result = new Map(); + + for (const item of board.items) { + if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue; + + const existingOpenInNewTab = result.get(item.options.appId) ?? false; + result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true); + } + + return result; + }, [board.items]); + const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]); + const activeAppIds = isSpotlightActivated ? appIds : []; + const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id }))); + const apps = appQueries.flatMap((query) => query.data ?? []); + const spotlightItems = useMemo(() => { + return apps.flatMap((app) => { + const href = getSafeAppHref(app.href); + if (!href) return []; + + return [ + { + id: app.id, + dedupeKey: `app:${app.id}`, + name: app.name, + icon: app.iconUrl, + interaction() { + return { + type: "link" as const, + href, + // Opening a new tab wins if any widget instance requests it. + newTab: openInNewTabByAppId.get(app.id) ?? false, + }; + }, + }, + ]; + }); + }, [apps, openInNewTabByAppId]); + + useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]); + + return null; +}; diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 7d20f5bfc..2454eda9f 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useOptionalBoard } from "@homarr/boards/context"; +import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; @@ -41,6 +42,7 @@ interface BoardSwitcherProps { export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { const t = useI18n("board.action.switcher"); + const tBoard = useI18n("board"); const manageBoardsT = useI18n("management.page.board"); const currentBoard = useOptionalBoard(); const [isOpen, setIsOpen] = useState(false); @@ -85,6 +87,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { }, []); const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); + const spotlightAction = useMemo( + () => ({ + id: "open-board-switcher", + name: tBoard("action.switch"), + icon: IconLayoutBoard, + interaction: () => ({ + type: "javaScript" as const, + onSelect: openSwitcher, + }), + }), + [openSwitcher, tBoard], + ); + useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]); + useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); useEffect(() => { diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx index 5fbc9f5ed..427988c74 100644 --- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import type { HotkeyItem } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks"; import { hotkeys } from "@homarr/definitions"; @@ -13,23 +14,18 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) loading: () => null, }); +const spotlightHotkeys: HotkeyItem[] = [ + [hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }], + [hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }], + [hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }], + [hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }], +]; + export const LazySpotlight = () => { const [isMounted, setIsMounted] = useState(false); const [isPreloaded, setIsPreloaded] = useState(false); - useHotkeys( - isMounted - ? [] - : [ - [ - hotkeys.openSpotlight, - () => { - setIsMounted(true); - openSpotlight(); - }, - ], - ], - ); + useHotkeys(spotlightHotkeys, [], true); useEffect(() => { let isActive = true; @@ -61,12 +57,8 @@ export const LazySpotlight = () => { return isMounted || isPreloaded ? ( <> {isPreloaded &&