diff --git a/nuxt/assets/css/style.css b/nuxt/assets/css/style.css
index 79076bf728..7c1876c4eb 100644
--- a/nuxt/assets/css/style.css
+++ b/nuxt/assets/css/style.css
@@ -478,8 +478,13 @@ dl.message-properties > dd {
@apply flex items-start justify-between gap-4 rounded-lg p-4 text-left;
}
+/* The site-wide `code` rule draws a bordered, padded box, which inside the dark block reads
+ as a second box. Pages that sit in .nohero reset it already; docs pages do not. */
.ff-command__text {
@apply text-sm text-gray-100 whitespace-pre-wrap break-all;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
}
.ff-command__copy {
@@ -999,8 +1004,11 @@ h4:hover .header-anchor::before {
font-weight: 400;
}
+ /* The grey line under the header is a shadow, not a border, so it adds nothing to the
+ height that --ff-header-height below has to match. */
.ff-website .ff-header {
@apply bg-white w-full px-6 py-4 sm:py-6 md:py-0 top-0 z-50 sticky;
+ box-shadow: 0 1px 0 var(--color-gray-200);
}
/* The header pins itself to the top of the viewport, so anything else that wants to
@@ -2448,6 +2456,62 @@ lite-youtube::before {
outline-offset: 3px;
}
+/* Docs and handbook search: the field moves between the docs home's hero and a page's
+ sidebar, and into the search dialog and back (AlgoliaSearch.vue, `morph`). On a page
+ change the page itself swaps at once rather than cross-fading
+ (plugins/section-view-transitions.client.ts sets the class). */
+html.ff-section-transition::view-transition-old(root) {
+ display: none;
+}
+
+html.ff-section-transition::view-transition-new(root) {
+ animation: none;
+}
+
+/* The moving box carries the field's white, and its two snapshots ride inside it at their
+ real size, so the text cross-fades without stretching while the box changes shape. */
+::view-transition-group(ff-search) {
+ animation-duration: 0.3s;
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ border-radius: 6px;
+ background: #fff;
+ overflow: clip;
+}
+
+::view-transition-old(ff-search),
+::view-transition-new(ff-search) {
+ width: 100%;
+ height: 100%;
+ object-fit: none;
+ object-position: left center;
+}
+
+/* The old text is gone before the new one is fully in, so the two never read as one. */
+::view-transition-old(ff-search) {
+ animation-duration: 0.12s;
+}
+
+::view-transition-new(ff-search) {
+ animation-duration: 0.3s;
+}
+
+/* While the field moves, the moving box draws the frame, and the field and the search dialog
+ leave theirs out of the snapshots: two frames of different sizes would show as a box in a
+ box. carries the class from just before a transition until it has finished. */
+html.ff-search-transition::view-transition-group(ff-search) {
+ box-sizing: border-box;
+ border: 1px solid #d1d5db;
+}
+
+html.ff-search-transition .ff-algolia--detached #algolia-search .aa-DetachedSearchButton,
+html.ff-search-transition .ff-algolia--detached .ff-algolia__standin {
+ border-color: transparent;
+}
+
+html.ff-search-transition .ff-search-dialog {
+ box-shadow: none;
+}
+
/* View transition names for 'morphing-content' are set dynamically via JavaScript for AI modal transitions */
/* View Transition styles for smooth morphing */
diff --git a/nuxt/components/AlgoliaSearch.vue b/nuxt/components/AlgoliaSearch.vue
index 7cfab36266..cf888ffa64 100644
--- a/nuxt/components/AlgoliaSearch.vue
+++ b/nuxt/components/AlgoliaSearch.vue
@@ -1,4 +1,6 @@
-
+
+
+
+ {{ isMac ? '⌘' : 'Ctrl' }} K
+
+
+
+
+
diff --git a/nuxt/components/AppHeader.vue b/nuxt/components/AppHeader.vue
index d9b297485f..b482bbd12e 100644
--- a/nuxt/components/AppHeader.vue
+++ b/nuxt/components/AppHeader.vue
@@ -11,6 +11,12 @@ const hl = (key) => {
const resolveHref = useResolveHref()
+const route = useRoute()
+const isDocs = computed(() => route.path === '/docs' || route.path.startsWith('/docs/'))
+// Docs and handbook pages put their search in the sidebar, which folds away below lg; the
+// header carries a search button there instead.
+const hasSectionSearch = computed(() => isDocs.value || route.path === '/handbook' || route.path.startsWith('/handbook/'))
+
onMounted(() => {
const navToggle = document.getElementById('nav-toggle')
if (navToggle) {
@@ -119,17 +125,20 @@ onMounted(() => {
-
+
+
+
diff --git a/nuxt/components/DocsHeaderActions.vue b/nuxt/components/DocsHeaderActions.vue
new file mode 100644
index 0000000000..8e75ab7256
--- /dev/null
+++ b/nuxt/components/DocsHeaderActions.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
diff --git a/nuxt/content-guides/index.md b/nuxt/content-guides/index.md
new file mode 100644
index 0000000000..699909d3d1
--- /dev/null
+++ b/nuxt/content-guides/index.md
@@ -0,0 +1,249 @@
+---
+navTitle: Documentation
+metaTitle: Documentation
+landing: true
+meta:
+ description: FlowFuse documentation. Get started on FlowFuse Cloud, on your own hardware or with your AI agent, then build, deploy and run industrial applications.
+---
+
+::docs-hero
+# Start building with FlowFuse
+
+Build industrial applications, then deploy and run them from cloud to edge.
+
+#actions
+- [:icon{name="i-lucide-play"} Getting started](/docs/user/introduction/)
+- [:icon{name="i-lucide-book-open"} API reference](/docs/api/)
+
+#aside
+:::docs-choices{title="Where do you want to start?"}
+::::docs-choice{label="FlowFuse Cloud" icon="i-lucide-cloud"}
+FlowFuse runs the platform for you. Sign up, then build your applications and deploy them from your browser, with nothing to install. The quickest way to try FlowFuse.
+
+- [Get started on FlowFuse Cloud](/docs/user/introduction/)
+- [About FlowFuse Cloud](/docs/cloud/introduction/)
+::::
+
+::::docs-choice{label="Your own edge hardware, with the Device Agent" icon="i-lucide-cpu"}
+Run your applications on your own machines at the edge, from an industrial PC to a Raspberry Pi, and manage them all from FlowFuse. The Device Agent connects each machine as a remote instance, to FlowFuse Cloud or to a self-hosted platform.
+
+- [Connect your first machine](/docs/device-agent/quickstart/)
+- [About the Device Agent](/docs/device-agent/introduction/)
+::::
+
+::::docs-choice{label="FlowFuse Self-Hosted" icon="i-lucide-server"}
+Run the whole platform, and the applications on it, on your own infrastructure: on premises or in your own cloud, with Docker or Kubernetes. Talk to our sales team first: they help you choose the right licence and plan the installation with you.
+
+- [Contact sales](/contact-us/)
+- [About self-hosting](/docs/install/introduction/)
+::::
+
+::::docs-choice{label="Your AI agent" icon="i-lucide-bot"}
+Connect Claude, ChatGPT, Copilot or another agent to FlowFuse over MCP. It can manage your teams and applications, and build and edit their flows, within the access you grant it.
+
+- [Connect your agent](/docs/user/expert/third-party-agents/)
+- [About FlowFuse MCP](/docs/user/mcp/)
+::::
+
+::::docs-choice{label="Not sure yet? Talk to us" icon="i-lucide-messages-square" open}
+Tell us about the applications you want to build, and the sites and machines they run on. We work out with you which setup fits, then help you get it running. To understand the platform first, the Application Guide explains how FlowFuse applications are shaped.
+
+- [Book a call](/book-demo/)
+- [About FlowFuse applications](/docs/application-guide/)
+::::
+:::
+::
+
+::docs-section{eyebrow="Platform" title="Choose how you run FlowFuse"}
+Where FlowFuse runs is the one choice that changes your first steps. After that, the documentation is the same.
+
+:::card-group
+::::card{title="FlowFuse Cloud" icon="i-lucide-cloud"}
+Hosted by FlowFuse. Nothing to install: sign up and build in the browser.
+
+- [:icon{name="i-lucide-info"} About FlowFuse Cloud](/docs/cloud/introduction/)
+- [:icon{name="i-lucide-play"} Get started on FlowFuse Cloud](/docs/user/introduction/)
+- [:icon{name="i-lucide-receipt"} Billing](/docs/cloud/billing/)
+::::
+
+::::card{title="Self-hosted" icon="i-lucide-server"}
+The platform on your own infrastructure, with Docker, Kubernetes or a single machine. Start by talking to our sales team.
+
+- [:icon{name="i-lucide-messages-square"} Contact sales](/contact-us/)
+- [:icon{name="i-lucide-signpost"} Choosing how to install](/docs/install/introduction/)
+- [:icon{name="i-lucide-container"} Docker](/docs/install/docker/)
+- [:icon{name="i-lucide-ship-wheel"} Kubernetes](/docs/install/kubernetes/)
+- [:icon{name="i-lucide-settings"} Configuring FlowFuse](/docs/install/configuration/)
+::::
+:::
+
+:::docs-tiles
+- [:icon{name="i-lucide-cpu"} Remote instances with the Device Agent](/docs/device-agent/)
+- [:icon{name="i-lucide-arrow-right-left"} Moving from plain Node-RED](/docs/migration/)
+- [:icon{name="i-lucide-hard-drive"} Hardware guides](/docs/hardware/introduction/)
+:::
+::
+
+::docs-section{eyebrow="Documentation" title="Find what you need" cols="4"}
+Four kinds of page, for four different moments.
+
+:::card-group
+::::card{title="Learn by building" icon="i-lucide-graduation-cap"}
+Lessons that take you from nothing to something working.
+
+- [Build a weather dashboard](/blog/2025/12/getting-weather-data-in-node-red/)
+- [Build a machine downtime tracker](/blog/2026/07/build-downtime-logger/)
+- [Build a defect tracking dashboard](/blog/2026/07/defect-and-quality-monitoring/)
+::::
+
+::::card{title="Get something done" icon="i-lucide-list-checks"}
+Task guides, for when you know what you want.
+
+- [Working in FlowFuse](/docs/user/)
+- [Working in Node-RED](/docs/node-red/)
+- [MCP in your flows](/docs/flowfuse-nodes/mcp/)
+- [Administering FlowFuse](/docs/admin/)
+::::
+
+::::card{title="Look something up" icon="i-lucide-book-open"}
+Details you come back for rather than read once.
+
+- [Instance states](/docs/user/instance-states/)
+- [FlowFuse nodes](/docs/flowfuse-nodes/)
+- [Node-RED core nodes](/docs/node-red/core-nodes/)
+- [API](/docs/api/)
+::::
+
+::::card{title="Understand FlowFuse" icon="i-lucide-lightbulb"}
+What FlowFuse is, and how to shape an application on it.
+
+- [FlowFuse concepts](/docs/user/concepts/)
+- [Application Guide](/docs/application-guide/)
+- [Node-RED Guide](/docs/node-red-guide/)
+::::
+:::
+::
+
+::docs-section{eyebrow="Journey" title="From first flow to production"}
+Follow the lifecycle, or jump to what you need.
+
+:::steps{level="3"}
+### Get started
+Your first application and its first flow, and the people you build it with.
+
+::::docs-tiles
+- [:icon{name="i-lucide-play"} Getting started](/docs/user/introduction/)
+- [:icon{name="i-lucide-layout-dashboard"} Your first dashboard](/blog/2025/12/getting-weather-data-in-node-red/)
+- [:icon{name="i-lucide-shapes"} FlowFuse concepts](/docs/user/concepts/)
+- [:icon{name="i-lucide-users"} Teams](/docs/user/team/)
+- [:icon{name="i-lucide-arrow-right-left"} Move from Node-RED](/docs/migration/)
+::::
+
+### Build
+The flows, dashboards and data your application runs on.
+
+::::docs-tiles
+- [:icon{name="i-lucide-gauge"} Dashboards](/docs/user/dashboards/)
+- [:icon{name="i-lucide-sparkles"} FlowFuse Expert](/docs/user/expert/)
+- [:icon{name="i-lucide-bot"} Connect your own agent](/docs/user/expert/third-party-agents/)
+- [:icon{name="i-lucide-table"} FlowFuse Tables](/docs/user/ff-tables/)
+- [:icon{name="i-lucide-radio-tower"} Team Broker](/docs/user/teambroker/)
+- [:icon{name="i-lucide-variable"} Environment variables](/docs/user/envvar/)
+- [:icon{name="i-lucide-library"} Shared team library](/docs/user/shared-library/)
+::::
+
+### Deploy
+Promote tested work to production, in the cloud and at the edge.
+
+::::docs-tiles
+- [:icon{name="i-lucide-history"} Snapshots](/docs/user/snapshots/)
+- [:icon{name="i-lucide-git-branch"} DevOps pipelines](/docs/user/devops-pipelines/)
+- [:icon{name="i-lucide-cpu"} Deploy to remote instances](/docs/device-agent/deploy/)
+- [:icon{name="i-lucide-boxes"} Remote instance groups](/docs/user/device-groups/)
+- [:icon{name="i-lucide-copy"} High availability](/docs/user/high-availability/)
+- [:icon{name="i-lucide-globe"} Custom hostnames](/docs/user/custom-hostnames/)
+::::
+
+### Operate
+Access, visibility and cost, once it runs.
+
+::::docs-tiles
+- [:icon{name="i-lucide-shield"} Roles and permissions](/docs/user/role-based-access-control/)
+- [:icon{name="i-lucide-key-round"} Single sign-on](/docs/admin/sso/)
+- [:icon{name="i-lucide-scroll-text"} Logging](/docs/user/logs/)
+- [:icon{name="i-lucide-activity"} Monitoring](/docs/admin/monitoring/)
+- [:icon{name="i-lucide-settings"} Instance settings](/docs/user/instance-settings/)
+- [:icon{name="i-lucide-receipt"} Billing](/docs/cloud/billing/)
+::::
+:::
+::
+
+::docs-section{eyebrow="Resources" title="Keep learning" cols="3"}
+:::card-group
+::::card{icon="i-lucide-newspaper"}
+[Blog :icon{name="i-lucide-arrow-up-right"}](/blog/)
+
+Articles, how-tos and product news.
+::::
+
+::::card{icon="i-lucide-layout-template"}
+[Blueprint library :icon{name="i-lucide-arrow-up-right"}](/blueprints/)
+
+Ready-made flows to start an application from.
+::::
+
+::::card{icon="i-lucide-star"}
+[What's new :icon{name="i-lucide-arrow-up-right"}](/changelog/)
+
+Every release, feature by feature.
+::::
+
+::::card{icon="i-lucide-presentation"}
+[Webinars :icon{name="i-lucide-arrow-up-right"}](/webinars/)
+
+Live and recorded sessions with the team.
+::::
+
+::::card{icon="i-lucide-plug"}
+[Integrations :icon{name="i-lucide-arrow-up-right"}](/integrations/)
+
+Certified nodes for the systems you connect to.
+::::
+
+::::card{icon="i-lucide-youtube"}
+[YouTube :icon{name="i-lucide-arrow-up-right"}](https://www.youtube.com/channel/UCbBzP8NZbv3WDtlt4UouA-g)
+
+Walkthroughs and recorded talks.
+::::
+:::
+::
+
+::docs-section{eyebrow="Help" title="Ask a question" cols="2"}
+Put your question to our support team, the community, or the troubleshooting guides.
+
+:::card-group
+::::card{icon="i-lucide-headset"}
+[Talk to our support team :icon{name="i-lucide-arrow-up-right"}](/support/)
+
+Search the Help Center or submit a ticket. For Cloud and Enterprise customers.
+::::
+
+::::card{icon="i-lucide-bug"}
+[Debugging Node-RED](/docs/debugging/)
+
+Find out why a flow or an instance is not behaving.
+::::
+
+::::card{icon="i-lucide-messages-square"}
+[Community forum](/docs/community-support/)
+
+Ask the FlowFuse and Node-RED community.
+::::
+
+::::card{icon="i-lucide-activity"}
+[Platform status :icon{name="i-lucide-arrow-up-right"}](https://status.flowfuse.com/)
+
+FlowFuse Cloud availability.
+::::
+:::
+::
diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts
index d4475e9036..28afc5488c 100644
--- a/nuxt/content.config.ts
+++ b/nuxt/content.config.ts
@@ -57,6 +57,9 @@ export default defineContentConfig({
structuredData: z.object({
description: z.string().optional(),
}).optional(),
+ // A contents page that lays itself out (the docs home): no sidebar, no
+ // table of contents, the full page width. Read by pages/docs/[...slug].vue.
+ landing: z.boolean().optional(),
// No `sitemap` schema field here on purpose - @nuxtjs/sitemap's own
// @nuxt/content integration only accepts *plain* onUrl/filter functions
// (it re-splices their source text into a generated file with no closure
diff --git a/nuxt/lib/docs-sync.mjs b/nuxt/lib/docs-sync.mjs
index 766eceb612..010e196f9b 100644
--- a/nuxt/lib/docs-sync.mjs
+++ b/nuxt/lib/docs-sync.mjs
@@ -96,6 +96,15 @@ function gitOutput (cwd, args) {
}
}
+/**
+ * The README at the root of the flowfuse docs tree is that repository's own readme for its
+ * docs folder, not the portal's home page. /docs spans more than the flowfuse docs (the
+ * guides and the Node-RED library come from this repo), so its home is this repo's
+ * nuxt/content-guides/index.md. This file is therefore never copied, and syncing it
+ * neither writes nor removes the home page.
+ */
+export const FLOWFUSE_DOCS_README = 'README.md'
+
/**
* Where one source file lands: markdown becomes a page under content/, a README becomes
* its section index, and anything else is an asset served from public/.
@@ -141,6 +150,7 @@ function copyDocsDir ({ docsDir, sourceRoot, contentDocsDir, publicDocsDir, vers
if (entry.name.startsWith('.')) continue
const relPath = join(relDir, entry.name)
+ if (relPath === FLOWFUSE_DOCS_README) continue
const args = { docsDir, sourceRoot, contentDocsDir, publicDocsDir, version }
if (entry.isDirectory()) {
@@ -172,6 +182,8 @@ export function syncDocsPath ({ docsDir, nuxtRoot, relPath }) {
const contentDocsDir = join(nuxtRoot, 'content', 'docs')
const publicDocsDir = join(nuxtRoot, 'public', 'docs')
+ if (relPath === FLOWFUSE_DOCS_README) return
+
if (!existsSync(join(docsDir, relPath))) {
rmSync(destinationFor(relPath, contentDocsDir, publicDocsDir), { force: true })
return
diff --git a/nuxt/lib/docs-sync.test.mjs b/nuxt/lib/docs-sync.test.mjs
index 014c7692bb..2e2f890ebb 100644
--- a/nuxt/lib/docs-sync.test.mjs
+++ b/nuxt/lib/docs-sync.test.mjs
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { resolveSource, syncDocsPath } from './docs-sync.mjs'
+import { resolveSource, syncDocs, syncDocsPath } from './docs-sync.mjs'
const repoRoot = '/repo/website'
@@ -100,6 +100,40 @@ test('a README becomes the index page of its section', (t) => {
assert.ok(!existsSync(fx.content('cloud', 'README.md')))
})
+// The portal home is this repo's page (nuxt/content-guides/index.md); the README at the
+// root of the flowfuse docs tree is only that repo's own readme.
+test('the root README of the flowfuse docs is not written as the portal home', (t) => {
+ const fx = fixture(t)
+ writeFileSync(join(fx.docsDir, 'README.md'), '# FlowFuse docs\n')
+
+ syncDocsPath({ docsDir: fx.docsDir, nuxtRoot: fx.nuxtRoot, relPath: 'README.md' })
+
+ assert.ok(!existsSync(fx.content('index.md')))
+})
+
+test('syncing the root README leaves the portal home in place', (t) => {
+ const fx = fixture(t)
+ writeFileSync(fx.content('index.md'), 'the website home\n')
+
+ syncDocsPath({ docsDir: fx.docsDir, nuxtRoot: fx.nuxtRoot, relPath: 'README.md' })
+
+ assert.equal(readFileSync(fx.content('index.md'), 'utf8'), 'the website home\n')
+})
+
+test('a full sync copies every page but the root README', async (t) => {
+ const fx = fixture(t)
+ writeFileSync(join(fx.docsDir, 'README.md'), '# FlowFuse docs\n')
+ writeFileSync(join(fx.docsDir, 'cloud', 'README.md'), '# Cloud\n')
+ writeFileSync(join(fx.docsDir, 'cloud', 'billing.md'), '# Billing\n')
+ const silent = { info () {}, warn () {} }
+
+ await syncDocs({ repoRoot: join(fx.nuxtRoot, '..'), nuxtRoot: fx.nuxtRoot, env: { FLOWFUSE_DOCS_LOCAL: fx.docsDir }, logger: silent })
+
+ assert.ok(!existsSync(fx.content('index.md')))
+ assert.ok(existsSync(fx.content('cloud', 'index.md')))
+ assert.ok(existsSync(fx.content('cloud', 'billing.md')))
+})
+
test('a non-markdown file is copied to the public tree unchanged', (t) => {
const fx = fixture(t)
writeFileSync(join(fx.docsDir, 'cloud', 'diagram.svg'), '')
diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts
index cc2943714b..6b167bc3c7 100644
--- a/nuxt/nuxt.config.ts
+++ b/nuxt/nuxt.config.ts
@@ -362,7 +362,15 @@ export default defineNuxtConfig({
'docs-links': join(__dirname, 'utils/remark-docs-links'),
},
+ // Registers Nuxt's view transition router hook. It stays off by default: only docs and
+ // handbook pages turn it on (definePageMeta), so their search field can move between the
+ // docs home and a docs page; see plugins/section-view-transitions.client.ts.
+ experimental: {
+ viewTransition: true,
+ },
+
app: {
+ viewTransition: false,
head: {
// nuxt-seo-utils' default title template is `%s %separator %siteName`; the
// separator defaults to '|' if unset. This makes every page's
/og:title
diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue
index 635cbb20f7..a64a0fc728 100644
--- a/nuxt/pages/docs/[...slug].vue
+++ b/nuxt/pages/docs/[...slug].vue
@@ -2,7 +2,9 @@
import { useDocsNavTree, findDocsBreadcrumb, findDocsSurround } from '~/composables/useDocsNav'
import { docsSeo } from '~/lib/docs-seo.mjs'
-definePageMeta({ layout: 'default' })
+// A view transition between docs pages carries the search field from the docs home's hero
+// into the sidebar and back (plugins/section-view-transitions.client.ts).
+definePageMeta({ layout: 'default', viewTransition: true })
const route = useRoute()
const slugParts = computed(() =>
@@ -29,6 +31,10 @@ if (!page.value) {
// Empty on most docs pages: only the ones a catalog feature names as its docsLink get badges.
const plans = useDocsPlans(contentPath)
+// A contents page such as the docs home lays itself out from its own components, so it
+// gets the full width: no sidebar, no table of contents, no previous and next cards.
+const isLanding = computed(() => (page.value as { landing?: boolean } | null)?.landing === true)
+
// /docs is assembled from two repos, so "Edit this page" has to point at whichever one
// owns the page. Guides overlaid from this repo carry a ready-made `editUrl` (stamped by
// nuxt/lib/guides-sync.mjs); everything else came from FlowFuse/flowfuse and is addressed
@@ -108,7 +114,18 @@ const surround = computed(() => {
-
Sign in to edit·
diff --git a/nuxt/plugins/section-view-transitions.client.ts b/nuxt/plugins/section-view-transitions.client.ts
new file mode 100644
index 0000000000..fc9c0f5012
--- /dev/null
+++ b/nuxt/plugins/section-view-transitions.client.ts
@@ -0,0 +1,24 @@
+// Docs and handbook pages turn on Nuxt's view transitions (definePageMeta), so their search
+// field can move between the docs home's hero and a page's sidebar. The rest of the page
+// swaps at once, as it does without a transition: assets/css/style.css drops the page-wide
+// cross-fade while carries `ff-section-transition`, and draws the moving field's frame
+// while it carries `ff-search-transition`.
+import { SEARCH_TRANSITION_NAME, isInViewport } from '~/utils/viewTransition'
+
+export default defineNuxtPlugin((nuxtApp) => {
+ nuxtApp.hook('page:view-transition:start', (transition) => {
+ const html = document.documentElement
+ html.classList.add('ff-section-transition', 'ff-search-transition')
+
+ // The outgoing page is captured on the next frame, after this hook. A search field
+ // scrolled out of view would fly in from off screen, so it is left out.
+ const offscreen = [...document.querySelectorAll('[data-ff-search-morph]')]
+ .filter(el => el.style.viewTransitionName === SEARCH_TRANSITION_NAME && !isInViewport(el))
+ offscreen.forEach(el => { el.style.viewTransitionName = 'none' })
+
+ transition.finished.catch(() => {}).finally(() => {
+ html.classList.remove('ff-section-transition', 'ff-search-transition')
+ offscreen.forEach(el => { el.style.viewTransitionName = SEARCH_TRANSITION_NAME })
+ })
+ })
+})
diff --git a/nuxt/utils/viewTransition.ts b/nuxt/utils/viewTransition.ts
new file mode 100644
index 0000000000..907af31fab
--- /dev/null
+++ b/nuxt/utils/viewTransition.ts
@@ -0,0 +1,18 @@
+// Shared by the docs and handbook view transitions: the page change in
+// plugins/section-view-transitions.client.ts and the search dialog in AlgoliaSearch.vue.
+
+/** The view-transition-name the docs and handbook search fields share. */
+export const SEARCH_TRANSITION_NAME = 'ff-search'
+
+/** Whether a view transition can run here, and the reader has not asked for less motion. */
+export function canViewTransition (): boolean {
+ return typeof document !== 'undefined'
+ && 'startViewTransition' in document
+ && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
+}
+
+/** Whether any part of the element is on screen. A hidden element (display: none) is not. */
+export function isInViewport (el: Element): boolean {
+ const rect = el.getBoundingClientRect()
+ return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight
+}