Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion nuxt/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ dist/
/public/img/
/public/_includes/
/public/sitemap-legacy.xml
/public/js/ai-expert-modal.js
/public/js/hm-promo-banner.js

# Dependencies (hoisted to workspace root)
Expand Down
113 changes: 113 additions & 0 deletions nuxt/components/DocsAskFab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script setup lang="ts">
// "Ask Docs": the one entry point to FlowFuse Expert on every docs page, floating in the
// bottom-right corner at every screen width. It steps aside while a page's own question
// box (FfExpertAsk) is on screen, since that box opens the same conversation.
//
// It wears FlowFuse Expert's colours, as the Expert button in the FlowFuse app does: the
// FlowFuse mark, and a slowly turning brand-red to indigo border. Its box matches the
// header's Book a demo button (40px tall, 16px text, 8px 16px padding), so the two calls
// to action read as one family; the pill shape marks this one as floating.
const inlineVisible = useExpertInlineVisible()

function open () {
const capture = (window as any).capture
if (typeof capture === 'function') capture('cta-ai-get-started', { position: 'docs-fab' })
openDocsExpert()
}
</script>

<template>
<Transition name="ff-ask-fab">
<button
v-show="!inlineVisible"
type="button"
class="ff-ask-fab"
title="Ask FlowFuse Expert about the docs"
@click="open"
>
<img :src="'/images/ff-minimal-red.svg'" alt="" class="ff-ask-fab__mark">
<span>Ask Docs</span>
</button>
</Transition>
</template>

<style scoped>
@property --ff-ask-fab-angle {
syntax: '<angle>';
inherits: false;
initial-value: 135deg;
}

.ff-ask-fab {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
z-index: 60;
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 8px 18px 8px 14px;
border: 1.5px solid transparent;
border-radius: 9999px;
background:
linear-gradient(#fff, #fff) padding-box,
conic-gradient(from var(--ff-ask-fab-angle), #DA3D0B, #9333EA, #4F46E5, #9333EA, #DA3D0B) border-box;
color: #111827;
font-size: 1rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 4px 8px rgb(17 24 39 / 0.08), 0 12px 28px -2px rgb(79 70 229 / 0.22);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}

@media (prefers-reduced-motion: no-preference) {
.ff-ask-fab {
animation: ff-ask-fab-swirl 9s linear infinite;
}
}

.ff-ask-fab:hover {
background:
linear-gradient(#f5f5ff, #f5f5ff) padding-box,
conic-gradient(from var(--ff-ask-fab-angle), #DA3D0B, #9333EA, #4F46E5, #9333EA, #DA3D0B) border-box;
transform: translateY(-1px);
box-shadow: 0 6px 12px rgb(17 24 39 / 0.1), 0 16px 32px -4px rgb(79 70 229 / 0.3);
}

.ff-ask-fab:focus-visible {
outline: 2px solid #4F46E5;
outline-offset: 3px;
}

.ff-ask-fab__mark {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}

@media (max-width: 639px) {
.ff-ask-fab {
right: 1rem;
bottom: 1rem;
}
}

@keyframes ff-ask-fab-swirl {
from { --ff-ask-fab-angle: 135deg; }
to { --ff-ask-fab-angle: 495deg; }
}

.ff-ask-fab-enter-active,
.ff-ask-fab-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}

.ff-ask-fab-enter-from,
.ff-ask-fab-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>
55 changes: 55 additions & 0 deletions nuxt/components/DocsExpertBrand.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script setup lang="ts">
// "FlowFuse Expert", the same in the page's question box (FfExpertAsk) and in the header of
// the conversation modal (DocsExpertHost). When the box morphs into the modal,
// nuxt/public/js/ai-expert-modal.js finds both by data-ff-expert-brand and flies the box's one up
// into the modal header, so the two have to stay the same size.
defineProps<{
uid: string
/** Render "Expert" as the dialog's heading, with this id for its aria-labelledby. */
headingId?: string
}>()
</script>

<template>
<div class="ff-expert-brand" data-ff-expert-brand>
<span class="ff-expert-brand__wordmark" role="img" aria-label="FlowFuse">
<FlowFuseWordmark :uid="uid" />
</span>
<h2 v-if="headingId" :id="headingId" class="ff-expert-brand__product">Expert</h2>
<span v-else class="ff-expert-brand__product">Expert</span>
</div>
</template>

<style scoped>
/* Only as wide as its content, in the box as in the modal, so the flight between them moves
it without stretching it. */
.ff-expert-brand {
display: flex;
align-items: center;
gap: 0.25rem;
width: fit-content;
}

.ff-expert-brand__wordmark {
display: inline-block;
width: 6rem;
height: 1.5rem;
line-height: 0;
}

.ff-expert-brand__product {
margin: 0 0 -0.25rem;
font-size: 1rem;
line-height: 1.5rem;
font-weight: 600;
color: #374151;
}
</style>

<style>
/* Same pace as the box morphing into the input area (src/css/style.css, morphing-content). */
::view-transition-group(ff-expert-brand) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
}
</style>
71 changes: 71 additions & 0 deletions nuxt/components/DocsExpertHost.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
// The FlowFuse Expert conversation modal, rendered once for every docs page by the layout.
// nuxt/public/js/ai-expert-modal.js drives it (see nuxt/composables/useDocsExpert.ts). The element ids
// and the classes that script selects on are load-bearing: keep them as they are.
onMounted(() => {
mountDocsExpert().catch(() => {})
})
</script>

<template>
<div
id="ai-expert-modal"
class="fixed inset-0 bg-black/50 hidden items-center justify-center p-0 md:p-4 md:px-8 md:py-8 z-[9999]"
role="dialog"
aria-modal="true"
aria-labelledby="ai-expert-modal-title"
data-welcome="Hello! Ask me anything about FlowFuse or Node-RED, and I will answer from the documentation."
>
<div class="bg-white rounded-none md:rounded-lg shadow-xl w-full max-w-4xl h-full md:h-full flex flex-col relative">
<div class="ai-chat-box">
<div class="textarea-wrapper modal-header flex items-center justify-between px-4 py-3 rounded-t-none md:rounded-t-lg h-auto rounded-b-none mb-0">
<DocsExpertBrand uid="expert-modal" heading-id="ai-expert-modal-title" />
<button id="close-modal" type="button" class="text-gray-500 hover:text-gray-600" aria-label="Close dialog">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>

<div class="flex-1 p-4 overflow-y-auto">
<div class="bg-indigo-100 rounded-lg mb-6 py-3 px-4">
<p class="text-indigo-700 text-sm m-0">
AI agent has access to all of FlowFuse's
<a href="/docs" class="underline">documentation and knowledge</a>,
<a href="/blog" class="underline">blogposts</a>, and more.
</p>
</div>
<div id="chat-messages" class="space-y-4 overflow-auto" />
</div>

<div class="p-4 bg-white rounded-b-none md:rounded-b-lg border-t border-gray-200">
<div id="action-buttons-container" class="pb-4 flex justify-between">
<button id="clear-conversation" type="button" class="bg-white border border-indigo-300 rounded-full text-sm py-2 px-3 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
Start over
</button>
<div id="right-buttons" class="flex gap-2">
<button id="continue-to-app" type="button" class="textarea-wrapper bg-white rounded-full text-sm py-2 px-3 disabled:opacity-50 disabled:cursor-not-allowed transition-colors hidden inline-flex items-center gap-1">
Continue chat in <img :src="'/images/ff-minimal-red.svg'" alt="FlowFuse" class="w-5 h-5 inline-block">
</button>
<button id="stop-generation" type="button" class="bg-white border border-indigo-300 rounded-full text-sm flex items-center gap-2 py-2 px-3 hidden">
<div class="w-3 h-3 bg-gray-800 rounded-sm" />
Stop
</button>
<button id="send-message" type="button" class="bg-indigo-600 text-white border border-indigo-600 rounded-full text-sm py-2 px-3 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-indigo-700 transition-colors" disabled>
Send
</button>
</div>
</div>
<div class="relative">
<textarea
id="modal-input"
placeholder="Ask a question, or give more detail about what you are trying to do"
class="w-full p-4 border-2 border-gray-300 rounded-lg resize-none focus:outline-none focus:border-indigo-500 text-gray-900 h-24 text-sm disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
</div>
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion nuxt/components/DocsHeaderActions.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
// Docs search for the site header, where the docs sidebar is not on screen. AppHeader shows
// it below lg, on docs pages only.
// it below lg, on docs pages only. FlowFuse Expert is the Ask Docs button (DocsAskFab).
const route = useRoute()
const isDocs = computed(() => route.path === '/docs' || route.path.startsWith('/docs/'))

Expand Down
40 changes: 40 additions & 0 deletions nuxt/components/content/DocsColumns.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
// Two blocks side by side on wide screens, the first twice as wide as the second, stacked
// on narrow ones. The docs home uses it to put the Expert question box next to the card
// that leads to human support, so the section spans the page.
//
// ::docs-columns
// :::ff-expert-ask
// :::
//
// :::card{title="..." to="..."}
// ...
// :::
// ::
</script>

<template>
<div class="ff-docs-columns not-prose">
<slot />
</div>
</template>

<style scoped>
.ff-docs-columns {
display: grid;
gap: 1.5rem;
}

/* The two blocks stretch to one height (grid items do by default), so a box beside a
taller card grows to match it. */
@media (min-width: 1024px) {
.ff-docs-columns {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
}

.ff-docs-columns > :deep(*) {
margin-top: 0;
margin-bottom: 0;
}
</style>
15 changes: 12 additions & 3 deletions nuxt/components/content/DocsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,26 @@ const anchor = computed(() => props.id || props.title.toLowerCase().replace(/[^a
gap: 1rem;
}

/* Cards that follow a two-column block (the Expert box and its neighbour) keep their
distance from it. */
.ff-docs-section__body > :deep(.ff-docs-columns + .grid) {
margin-top: 1.5rem;
}

/* Without a lead line the cards would sit right under the heading. */
.ff-docs-section__body > :deep(.grid:first-child) {
margin-top: 2rem;
}

/* Cards hold a line or two, not an article: tighter than Nuxt UI's default card. */
.ff-docs-section__body > :deep(.grid > div) {
/* Cards hold a line or two, not an article: tighter than Nuxt UI's default card. The same
for a card in a card-group and one beside the Expert box in docs-columns. */
.ff-docs-section__body > :deep(.grid > div),
.ff-docs-section__body > :deep(.ff-docs-columns > .group) {
padding: 1.25rem;
}

.ff-docs-section__body > :deep(.grid > div p) {
.ff-docs-section__body > :deep(.grid > div p),
.ff-docs-section__body > :deep(.ff-docs-columns > .group p) {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
Expand Down
Loading
Loading