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 @@ + + + + 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/components/DocsLeftNav.vue b/nuxt/components/DocsLeftNav.vue index 111f65f44f..26609cf3e2 100644 --- a/nuxt/components/DocsLeftNav.vue +++ b/nuxt/components/DocsLeftNav.vue @@ -7,8 +7,9 @@ const route = useRoute() const { data: navGroups } = await useDocsNavTree() +// No "Documentation" entry on top: the search button sits there, and the breadcrumb and +// the site header both lead back to /docs. const items = computed((): NavigationMenuItem[] => [ - { label: 'Documentation', to: '/docs/' }, ...(navGroups.value ?? []).flatMap(group => [ { type: 'label', label: group.name } satisfies NavigationMenuItem, ...buildNavigationMenuItems(group.children, route.path), @@ -17,5 +18,9 @@ const items = computed((): NavigationMenuItem[] => [ diff --git a/nuxt/components/HandbookLeftNav.vue b/nuxt/components/HandbookLeftNav.vue index a6000f9190..a5e844d9e7 100644 --- a/nuxt/components/HandbookLeftNav.vue +++ b/nuxt/components/HandbookLeftNav.vue @@ -42,5 +42,9 @@ const navItems = computed((): NavigationMenuItem[] => { diff --git a/nuxt/components/HandbookSearch.vue b/nuxt/components/HandbookSearch.vue deleted file mode 100644 index 827c6488d7..0000000000 --- a/nuxt/components/HandbookSearch.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/nuxt/components/SidebarNav.vue b/nuxt/components/SidebarNav.vue index 4dd32dfe6c..e3977e146f 100644 --- a/nuxt/components/SidebarNav.vue +++ b/nuxt/components/SidebarNav.vue @@ -68,3 +68,20 @@ watch(() => route.path, () => { open.value = false }) + + diff --git a/nuxt/components/SidebarWithSearch.vue b/nuxt/components/SidebarWithSearch.vue new file mode 100644 index 0000000000..61c6573667 --- /dev/null +++ b/nuxt/components/SidebarWithSearch.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/nuxt/components/content/DocsChoice.vue b/nuxt/components/content/DocsChoice.vue new file mode 100644 index 0000000000..fd98faea09 --- /dev/null +++ b/nuxt/components/content/DocsChoice.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/nuxt/components/content/DocsChoices.vue b/nuxt/components/content/DocsChoices.vue new file mode 100644 index 0000000000..66178b665c --- /dev/null +++ b/nuxt/components/content/DocsChoices.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/nuxt/components/content/DocsHero.vue b/nuxt/components/content/DocsHero.vue new file mode 100644 index 0000000000..5f7debdadc --- /dev/null +++ b/nuxt/components/content/DocsHero.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/nuxt/components/content/DocsSection.vue b/nuxt/components/content/DocsSection.vue new file mode 100644 index 0000000000..1cac7697c9 --- /dev/null +++ b/nuxt/components/content/DocsSection.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/nuxt/components/content/DocsTiles.vue b/nuxt/components/content/DocsTiles.vue new file mode 100644 index 0000000000..a9f5ec1a5f --- /dev/null +++ b/nuxt/components/content/DocsTiles.vue @@ -0,0 +1,108 @@ + + + + + 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(() => { </script> <template> - <div class="w-full pl-6"> + <!-- Not wrapped in .prose: every block on a landing page is a component that styles + itself, and Tailwind Typography's unlayered rules would fight them. --> + <div v-if="isLanding" class="ff-docs-landing w-full max-w-6xl mx-auto px-6 lg:px-10 pt-8 pb-24 text-left"> + <!-- The docs home has no trail to show: it would be the single word Docs. --> + <Breadcrumbs v-if="slugParts.length" :items="breadcrumbItems" /> + <ContentRenderer v-if="page" :value="page" /> + </div> + + <!-- lg:pr-6 mirrors the header's own padding, so the two centre alike and the sidebar + search lines up under the logo at every width (with pl-6 alone they parted by 12px + between 1280 and 1536, where both stop at the same max width). --> + <div v-else class="w-full pl-6 lg:pr-6"> <div class="handbook ff-prose text-left pb-24 m-auto"> <!-- Left navigation --> @@ -117,14 +134,11 @@ const surround = computed(() => { <!-- Main content area --> <div class="px-10 pt-8"> <div class="w-full"> - <!-- Breadcrumbs + Search bar --> + <!-- Breadcrumbs --> <div class="font-medium pb-1 flex flex-col gap-1"> <div class="md:flex-1"> <Breadcrumbs :items="breadcrumbItems" /> </div> - <div class="w-full mb-1"> - <AlgoliaSearch index-filter="category:docs" placeholder="Search in Docs..." source-id="docs" /> - </div> </div> </div> @@ -142,13 +156,18 @@ const surround = computed(() => { <!-- Right sidebar: TOC --> <div class="lg right-nav"> - <div class="sticky top-20 w-full mt-4 md:mt-6 px-8"> - <HandbookToc :links="page?.body?.toc?.links" /> - <div v-if="page?.updated" class="text-xs pb-1 text-right mt-4 text-gray-500 max-lg:hidden"> + <!-- On wide screens the column never runs past the bottom of the window: a long table + of contents scrolls inside itself, so the lines under it stay in view. + "On this page" lines up with the sidebar's search text and the breadcrumb: the + column starts where the search field does (mt-6), and pt-2.5 is half of the + field's 40px less one 20px line. --> + <div class="sticky top-20 w-full mt-4 md:mt-6 px-8 lg:flex lg:flex-col lg:max-h-[calc(100vh-7.5rem)]"> + <HandbookToc :links="page?.body?.toc?.links" :ui="{ root: 'lg:min-h-0', container: 'lg:pt-2.5' }" /> + <div v-if="page?.updated" class="text-xs pb-1 text-right mt-4 text-gray-500 max-lg:hidden shrink-0"> Updated: <RelativeTime :value="page.updated" /> </div> <ClientOnly> - <div v-if="editHref" class="text-xs pb-1 text-right italic max-lg:hidden"> + <div v-if="editHref" class="text-xs pb-1 text-right italic max-lg:hidden shrink-0"> <a :href="editHref" target="_blank" rel="noopener">Edit this page</a> </div> </ClientOnly> diff --git a/nuxt/pages/handbook/[...slug].vue b/nuxt/pages/handbook/[...slug].vue index ffcf688aa6..ea9bfe654b 100644 --- a/nuxt/pages/handbook/[...slug].vue +++ b/nuxt/pages/handbook/[...slug].vue @@ -1,7 +1,8 @@ <script setup lang="ts"> import { findPageBreadcrumb } from '@nuxt/content/utils' -definePageMeta({ layout: 'default' }) +// As on docs pages: the sidebar search stays put while the page around it changes. +definePageMeta({ layout: 'default', viewTransition: true }) const route = useRoute() const slugParts = computed(() => @@ -91,7 +92,9 @@ defineOgImage('Default', { </script> <template> - <div class="light w-full pl-6"> + <!-- lg:pr-6 mirrors the header's own padding, as on docs pages, so the sidebar search + lines up under the logo at every width. --> + <div class="light w-full pl-6 lg:pr-6"> <div class="handbook ff-prose text-left pb-24 m-auto"> <!-- Left navigation --> @@ -100,14 +103,11 @@ defineOgImage('Default', { <!-- Main content area --> <div class="px-10 pt-8"> <div class="w-full"> - <!-- Breadcrumbs + Search bar --> + <!-- Breadcrumbs. The search sits at the top of the sidebar, as on docs pages. --> <div class="font-medium pb-1 flex flex-col gap-1"> <div class="md:flex-1"> <Breadcrumbs :items="breadcrumbItems" /> </div> - <div class="w-full mb-1"> - <HandbookSearch /> - </div> </div> </div> @@ -123,14 +123,16 @@ defineOgImage('Default', { <!-- Right sidebar: TOC --> <div class="lg right-nav"> - <div class="sticky top-20 w-full mt-4 md:mt-6 px-8"> - <HandbookToc :links="page?.body?.toc?.links" /> + <!-- Laid out as on docs pages: on wide screens a long table of contents scrolls inside + itself, and "On this page" lines up with the sidebar search and the breadcrumb. --> + <div class="sticky top-20 w-full mt-4 md:mt-6 px-8 lg:flex lg:flex-col lg:max-h-[calc(100vh-7.5rem)]"> + <HandbookToc :links="page?.body?.toc?.links" :ui="{ root: 'lg:min-h-0', container: 'lg:pt-2.5' }" /> <!-- Client-only, as pages/docs/[...slug].vue does with its own edit link: /_studio is a server route with no prerendered file, so hyperlink (the build's link checker, which cannot be told to ignore a path) reads it as a broken link when it is in the static HTML. --> <ClientOnly> - <div class="text-xs pb-1 text-right mb-4 italic max-lg:hidden"> + <div class="text-xs pb-1 text-right mb-4 italic max-lg:hidden shrink-0"> <template v-if="!isStudioSignedIn"> <a :href="studioEditUrl">Sign in to edit</a> <span class="px-1" aria-hidden="true">·</span> 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 <html> 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<HTMLElement>('[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 +}